作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
学习必须往深处挖,挖的越深,基础越扎实!
阶段1、深入多线程
阶段2、深入多线程设计模式
阶段3、深入juc源码解析
阶段4、深入jdk其余源码解析
阶段5、深入jvm源码解析
最近在读一些闲书,包括一些心理及脑科学方面的科普书籍。其中有一本书叫《打开心智》,讲到了大脑的4个底层原理:
关于第二点:稳定,它是大脑的定位系统。大脑总会倾向于维持现状,希望一切是确定的、已知的、可控的,这样才能获得安全感,维持现有的心智秩序。
反映到程序员的日常工作中,体现在:如果系统已有实现,就不应该重复造轮子。而且如果已有的实现(稳定运行)与自己的理解有偏差,首先想到的是自己是否理解有误。诚然,这是非常自然的一种思维习惯。但前阵子在做一个积分售后的逻辑时,我却陷入了麻烦。
这一篇文章比较特殊,它更多地是对一个业务逻辑合理性的探讨,本质上并不需要代码,但为了更好地说明问题,我会辅以简单的一些示例。
OK,让我们开始。
很多商城类的应用,底层都会有一个积分系统。基于积分系统,可以衍生出很多业务玩法,比如:
一个最简单的积分系统可能就包括2张表:总账户表、流水表。如果你需要支持积分过期和回滚,可能还要加周期积分表、使用明细等。由于具体的设计细节与接下来要介绍的问题关系不大,这里对积分表做适当简化:
账户表
id | user_id | total_points | used_points | balance | update_time | create_time |
1 | 10086 | 10 | 5 | 5 | 2023-12-25 20:59:10 | 2023-12-25 20:59:10 |
流水表
id | user_id | biz_id | biz_type | op_type | points | update_time | create_time |
1 | 10086 | 123456789 | 1 | 1 | 5 | 2023-12-25 20:59:10 | 2023-12-25 20:59:10 |
2 | 10086 | 987654321 | 2 | 2 | -5 | 2023-12-25 20:59:10 | 2023-12-25 20:59:10 |
3 | 10086 | 123654321 | 3 | 1 | 5 | 2023-12-25 20:59:10 | 2023-12-25 20:59:10 |
以上面的表为例,接下来演示增加、减少、回滚积分对应的数据变化。由于流水表比较简单,这里只演示总账户表。
一开始用户的总账户为0(会在第一次充值积分时先初始化):
id | user_id | total_points | used_points | balance |
1 | 10086 | 0 | 0 | 0 |
假设用户下了一个订单送了10积分(ADD):
id | user_id | total_points(+10) | used_points(暂未使用) | balance(+10) |
1 | 10086 | 10 | 0 | 10 |
然后玩了一次积分抽奖,消耗了5积分(SUB):
id | user_id | total_points | used_points(+5) | balance(10-5) |
1 | 10086 | 10 | 5 | 5 |
接着用户用积分兑换了一个赠品,消耗了5积分(SUB):
id | user_id | total_points | used_points(+5+5) | balance(10-5-5) |
1 | 10086 | 10 | 10 | 0 |
我们可以发现:total_points = used_points + balance。
好,重点来了。假设现在用户对赠品不满意,进行了售后,此时系统需要回滚刚才的操作(ROLLBACK):
id | user_id | total_points | used_points(+5+5-5) | balance(10-5-5+5) |
1 | 10086 | 10 | 5 | 5 |
“回滚”在当前业务的概念是:上一步消耗了5积分,你得到了赠品。本质是客户用5积分换取了一个赠品。那么当客户申请售后时,用户如果把赠品退还给商城,那么商城也需要把积分归还给用户。所以数据的流向是:used_points的5积分退回到balance,但total_points是不会改变的,因为累计积分值并没有改变。
上述操作,用代码表述可能是这样的:
@Getter
@AllArgsConstructor
public enum PointsOpTypeEnum {
ADD(1, "增加"),
SUB(2, "扣减"),
ROLLBACK(3, "回滚"),
;
private final Integer type;
private final String desc;
}
@Override
public PointsAccountDO updatePointsAccount(Long userId, PointsOpTypeEnum opType, Double opPoints) {
PointsAccountDO pointsAccount = this.initAccount(userId);
if (pointsAccount == null) {
return null;
}
opPoints = Math.abs(opPoints);
switch (opType) {
case ADD:
// 添加积分,totalPoints和balance都会增加
pointsAccount.setTotalPoints(NumberUtil.add(pointsAccount.getTotalPoints(), opPoints));
pointsAccount.setBalance(NumberUtil.add(pointsAccount.getBalance(), opPoints));
break;
case SUB:
// 消耗积分,usedPoints增加,balance减少
pointsAccount.setUsedPoints(NumberUtil.add(pointsAccount.getUsedPoints(), opPoints));
pointsAccount.setBalance(NumberUtil.sub(pointsAccount.getBalance(), opPoints));
break;
case ROLLBACK:
// 回滚积分:使用积分减少,balance增加
pointsAccount.setUsedPoints(NumberUtil.sub(pointsAccount.getUsedPoints(), opPoints));
pointsAccount.setBalance(NumberUtil.add(pointsAccount.getBalance(), opPoints));
break;
default:
return null;
}
// 更新数据,省略...
}
截止目前为止,我们获取积分的途径有:
刚才上面演示了一个场景:用户用积分兑换商城的赠品后,因为不是很满意,进行了售后,于是我们使用了opType=ROLLBACK的操作,把积分重新归还到balance里。
但现在有个新场景。用户下了一个订单送了10积分(ADD):
id | user_id | total_points(+10) | used_points(暂未使用) | balance(+10) |
1 | 10086 | 10 | 0 | 10 |
然后玩了一次积分兑换赠品,消耗了5积分(SUB):
id | user_id | total_points | used_points(+5) | balance(10-5) |
1 | 10086 | 10 | 5 | 5 |
接着,我们后台的定时任务发现上面的订单售后退款了,此时会总账户表数据会如何变化?
很明显,由于产生积分的订单本身退款了,那么应该先调用updatePointsAccount()把发出去的积分扣回(SUB):
id | user_id | total_points | used_points(扣除订单10积分) | balance(余额也要扣除) |
1 | 10086 | 10 | 15 | -5 |
这样还不够,我们还需要把用户兑换的赠品回滚(ROLLBACK):
id | user_id | total_points | used_points(退回5积分到余额) | balance(+5) |
1 | 10086 | 10 | 10 | 0 |
此时total_points = used_points + balance仍然成立,用户余额归零,一切就像从未发生。
但是,真的是这样吗?
实际上,如果我们重新观察上的数据,就会发现一个诡异的现象:用户10086的toalPoints=10,used_points=10,balance=0,意味着用户曾经获得10积分,而且全部使用完毕。但是观察整个系统,却找不到这10积分到底花在哪了(赠品被扣回了)。所以,虽然balance=0是对的,但total_points=10和used_points=10却显得不合常理,因为用户明明啥都没得到,何来积分消耗?
那么问题出在哪呢?就在订单售后上。
我们习惯性地把订单售后和赠品售后等同看待,但两者其实是不同的:
所以,上面的逻辑把订单售后代表的逆向操作搞错了,此时应该是撤回(WITHDRAW),而不是减少(SUB):
@Getter
@AllArgsConstructor
public enum PointsOpTypeEnum {
ADD(1, "增加"),
SUB(2, "扣减"),
/**
* 回滚场景:
* 1.兑换赠品,消耗积分 total=5 use=5 balance=0
* 2.赠品售后 total=5 use=0 balance=5(把积分恢复为未使用)
*/
ROLLBACK(3, "回滚"),
/**
* 撤回场景:
* 1.通过下单得积分等方式,得到积分 total=5 use=0 balance=5
* 2.兑换赠品,消耗积分 total=5 use=5 balance=0
* 3.用户订单售后导致积分失效,商城需要回收之前发放的5积分 total=0 use=5 balance=-5【撤回积分】
* 4.回滚用户在商城兑换的商品 total=0 use=0 balance=0【回滚积分,因为商品被我们收回来了,积分也要还给用户】
*
* 最终数据回到原始状态 total=0 use=0 balance=0
*/
WITHDRAW(4, "撤回")
;
private final Integer type;
private final String desc;
}
所以,updatePointsAccount()还需要增加“撤回”的操作:
@Override
public PointsAccountDO updatePointsAccount(Long userId, PointsOpTypeEnum opType, Double opPoints) {
PointsAccountDO pointsAccount = this.initAccount(userId);
if (pointsAccount == null) {
return null;
}
opPoints = Math.abs(opPoints);
switch (opType) {
case ADD:
// 添加积分,totalPoints和balance都会增加
pointsAccount.setTotalPoints(NumberUtil.add(pointsAccount.getTotalPoints(), opPoints));
pointsAccount.setBalance(NumberUtil.add(pointsAccount.getBalance(), opPoints));
break;
case SUB:
// 消耗积分,usedPoints增加,balance减少
pointsAccount.setUsedPoints(NumberUtil.add(pointsAccount.getUsedPoints(), opPoints));
pointsAccount.setBalance(NumberUtil.sub(pointsAccount.getBalance(), opPoints));
break;
case ROLLBACK:
// 回滚积分:使用积分减少,balance增加
pointsAccount.setUsedPoints(NumberUtil.sub(pointsAccount.getUsedPoints(), opPoints));
pointsAccount.setBalance(NumberUtil.add(pointsAccount.getBalance(), opPoints));
break;
case WITHDRAW:
// 撤回积分:totalPoints减少,balance减少
pointsAccount.setTotalPoints(NumberUtil.sub(pointsAccount.getTotalPoints(), opPoints));
pointsAccount.setBalance(NumberUtil.sub(pointsAccount.getBalance(), opPoints));
break;
default:
return null;
}
// 更新数据,省略...
}
发生上面的问题,一方面是过于相信底层积分系统的设计者,毕竟已经稳定运行小半年,另一方面是自己没有仔细分辨SUB和WITHDRAW的区别。前者的业务逻辑是因业务场景消耗积分而扣除,而后者则是从根本上撤回积分(可能是因为操作本身不合法,因此需要扣回),两者存在本质差别。
后面大家遇到类似的场景时,也可以仔细想想现在系统内的一些模块提供的API是否合理?而不是一味地奉行“拿来主义”。
上面简化了案例,实际情况更加复杂一些,场景更具迷惑性。