幂等设计 - 每日任务领奖
幂等是分布式业务中极为核心的设计思想;
用幂等实现分布式事务的方案,适用场景应当满足如下两个条件:
- 业务最终必定成功,不需要回滚
- 业务自带重试的场景
# A.场景
王者荣耀完成每日任务,点击领奖按钮,会同时发放金币、钻石、经验。
金币、经验、钻石分属不同的系统,需要调用他们的HTTP接口进行发奖操作。
需求:设计一个领奖接口,保证奖励能发到位,并且不会重放。
# B.幂等
这道题一个关键点在于幂等设计,不是数学上的幂等,是分布式事务中的幂等设计。
网上有些言论称 ”form重复提交“ ”get是幂等“,”select是幂等/update不是幂等“,这些都只是表象。
分布式中的幂等设计是:任意多次执行所产生的影响均与一次执行的影响相同。
这句话网上搜来的,可以有很多延伸意思:
- 一个插入操作,无论执行多少次都只插入一条,这算幂等
- 一个自增的更新操作,无论执行多少次都只自增一次,这算幂等
# 1.做到幂等
实现幂等需要思考2个问题:
- 如何确定某事务是重复的操作?
- 如何保证操作只进行一次?
问题的答案是幂等三原则:
- 事务标识:想判断是不是重复操作,就要有一个能唯一确定某个事务的标识。
- 执行标识:想保证只操作一次,那就要记录一个标识,标记这个事务执行过了。
- 原子操作:标识的记录和事务的执行必须是原子的
第3点是极其关键的:要么都成功,要么都不成功!
此外,还可以延伸一下:如果A操作是幂等的,B操作也是幂等的,那么一个事务里如果只包含A操作和B操作,那它也是幂等的。
# 2.分布式锁是不是幂等
分布式锁一般是防并发的,最多算幂等设计的一部分。有时候甚至一部分都算不上。
我们可以预想一下操作步骤:
- 尝试加锁。没抢到锁退出;抢到后继续步骤2。
- 检查事务是否被处理
- 处理事务
- 修改事务处理状态
- 成功或出现异常后解锁
这里违背了幂等三原则第三条:原子操作。
步骤3和4不是原子的:假如第3步执行后,程序故障,4执行不了。那么下次请求过来,就会造成重复处理。
如果缓过来先执行步骤4呢?
假如先修改状态,再处理事务,中间程序故障,就会出现状态修改了,但是事务没处理的情况。
但是,如果步骤2,3,,4能合并成原子操作,甚至不加分布式锁也可以。
# C.错误方案
# 1.通过Redis记录发放状态
这个违背了幂等第3原则:原子性。
记录状态依赖Redis,而发放奖励是接口调用;Redis的操作和HTTP请求不可能是原子的,只要不是原子操作,就可能会出现不一致。
无论是先发奖再记录状态,还是先记录状态再发奖,只要两个操作中间遇到网络等硬件问题,这就变糊涂账了:
- 要么是标记发了,其实没发
- 要么是标记没发,其实发成功了,发了两笔
# 2.异步队列
这个方案不一定不能用,但是如果用这个方案能设计出可行的,一般也能用到同步的方式上。
考虑消费队列大多出于两个出发点:
- 避免并发
- 支持失败重试
其实对于出发点1,队列的消费一般是并发进行的,很少一个队列只有一个消费端。
假如同样的事务入队两次,那并发的场景是避免不了的。
对于出发点2,则要思考如何界定失败?
以Redis增加计数为例:如果incr
操作成功,但是因为网络原因没收到响应。此时应该算为消费失败,然后再次消费,那就会造成重复计数。
所以仅依赖异步队列,依然无法实现幂等。
而且消息队列反而要处理消息丢失的情况。
# 3.错误的事务唯一标识
问:“如何生成事务的唯一标识”
答:“雪花算法生成一个唯一id来当做这个事务的id”
这种回答是并没有真正理解“唯一标识”的作用。此唯一并非彼唯一!
假如用户点击按钮领奖时,每次请求过来都生成一个 uuid,的确是唯一了,但是并没有任何作用。
这里的唯一,是指能唯一确定一个事务的几个元素,需要是是一个唯一且固定的标识。
在这个领奖的案例中,“用户3月8日领每日任务”,能唯一确定这个事务的元素应该是:
- 日期:0308
- 用户 uid
- 每日任务:taskId
无论该用户今天点多少次按钮,每次请求都是这个唯一标识<date>:<uid><taskId>
,这才是这个事务的唯一标识。
# D.正确的方案
解决这个问题核心点有两个:
- 确定当前事务的唯一标识
- 要求下游接口幂等
下游接口的幂等设计才是关键点。
# 1.唯一标识
无论用户今天点多少次按钮,每次请求都是这个唯一标识<date>:<uid><taskId>
。
假设 20220309
领奖,假设uid是 123
,假设taskId是 456
。
那么这个事务的唯一标识 txId
就是 20220309:123:456
。中间要用分隔符隔开,防止跟 uid为12,完成3456任务的事件冲突。
# 2.下游接口的幂等
我们在请求下游发奖接口的时候,发金币、经验、钻石的时候,都要求其必须是幂等的。这样哪怕中间断了,我们也可以让用户重试来解决。
领奖接口的唯一标识也是调用下游接口的唯一标识,充其量添加一些标识,比如
- 金币:
20220309:123:456:coin
- 经验:
20220309:123:456:exp
那么下游接口如何保证幂等?
答案只能是数据库事务,Redis事务和MySQL事务都可以。由此可以得出:
分布式事务离不开本地事务
# 3.流程
用户完成任务这条记录的状态机中应当包含两个状态:已完成未领奖 和 已领奖
- 请求到来,为了减少数据库压力,可以加一个 redis 分布式锁(但不是必须的)
- 查询数据库中的状态是否已完成,已领奖。
- 已完成未领奖的依次调用接口,有一个失败或超时就直接报错返回。
- 所有的都成功,将状态改为已领奖
# E.小结
- 由于金币、经验、钻石都属于无限资源,因此最终必定能发成功,所以符合条件1;
- 如果用户领奖失败,用户会自己点击按钮领奖,所以属于业务自带重试场景,符合条件2;