警惕已有逻辑的不完美

作者简介:大家好,我是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

  • total_points:累计积分,比如你充了5积分、用了5积分、再充5积分,那么total_points=10
  • used_points:已使用积分,5积分
  • balance:可用余额,5积分

流水表

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

  • biz_id:业务id,比如你是因为在商城下单得到该积分,那么biz_id可能是订单号
  • biz_type:业务类型,比如1是“下单积分”,2是“积分抽奖消耗积分”,3是“签到积分”
  • op_type:操作类型,与业务类型不同的是,操作类型只有:1增加、2减少、3回滚

积分系统数据演示

以上面的表为例,接下来演示增加、减少、回滚积分对应的数据变化。由于流水表比较简单,这里只演示总账户表。

一开始用户的总账户为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):

警惕已有逻辑的不完美_第1张图片

id

user_id

total_points

used_points(扣除订单10积分)

balance(余额也要扣除)

1

10086

10

15

-5

  • used_points:兑换赠品消耗了5积分,此时订单售后,原本的10积分收回,所以used_points=15
  • balance:订单的10积分不存在了,但兑换赠品消耗了5积分,所以余额被透支成负数

这样还不够,我们还需要把用户兑换的赠品回滚(ROLLBACK):

警惕已有逻辑的不完美_第2张图片

id

user_id

total_points

used_points(退回5积分到余额)

balance(+5)

1

10086

10

10

0

  • used_points:赠品被系统扣回,所以积分也要还给用户

此时total_points = used_points + balance仍然成立,用户余额归零,一切就像从未发生。

但是,真的是这样吗?

实际上,如果我们重新观察上的数据,就会发现一个诡异的现象:用户10086的toalPoints=10,used_points=10,balance=0,意味着用户曾经获得10积分,而且全部使用完毕。但是观察整个系统,却找不到这10积分到底花在哪了(赠品被扣回了)。所以,虽然balance=0是对的,但total_points=10和used_points=10却显得不合常理,因为用户明明啥都没得到,何来积分消耗?

问题出在哪?

那么问题出在哪呢?就在订单售后上。

我们习惯性地把订单售后和赠品售后等同看待,但两者其实是不同的:

  • 赠品售后:兑换赠品时,used_points+5,balance-5,所以赠品售后时产生的逆向操作是used_points-5,balance+5
  • 订单售后:下单后系统自动为用户发放10积分,即total_points+10,balance+10,那么逆向操作应该是total_points-10,balance-10,而不是used_points+10,balance-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是否合理?而不是一味地奉行“拿来主义”。

上面简化了案例,实际情况更加复杂一些,场景更具迷惑性。

你可能感兴趣的:(生产故障,积分系统)