实现幂等的方式
背景
并发情况下,对于同一笔业务操作,不管调用多少次,得到的结果都应该是一致的,主要针对数据写操作。
举例场景
以支付宝充值为例子
解决方式
1.普通方式
1.1 接收到支付宝支付成功请求
1.2 根据trade_no查询当前订单是否处理过
1.3 如果订单已处理直接返回,若未处理,继续向下执行
1.4 开启本地事务
1.5 给用户加钱
1.6 将订单状态置为成功
1.7 提交本地事务
上面的过程,对于同一笔订单,如果支付宝同时通知多次,到第
1.2
步的时候,查询订单都是未处理的,程序向下执行,最终会给用户加多次钱,未解决幂等
2.加锁方式
2.1 接收到支付宝支付成功请求
2.2 加锁
2.3 根据trade_no查询当前订单是否处理过
2.4 如果订单已处理直接返回,若未处理,继续向下执行
2.5 开启本地事务
2.6 给用户加钱
2.7 将订单状态置为成功
2.8 提交本地事务
2.9 释放锁
集群分布式部署的时候,负载均衡到不同的机器,本地锁就无效了,这时需要实现分布式锁了。
3.悲观锁方式
使用数据库中的悲观锁实现,即 for update
实现
3.1 接收到支付宝支付成功请求
3.2 打开本地事务
3.3 查询订单信息并加悲观锁
select * from t_order where order_id={trade_no} for update;
3.4 判断订单是否已处理
3.5 如果订单已处理直接返回,若未处理,继续向下执行
3.6 给用户加钱
3.7 将订单状态置为成功
3.8 提交本地事务
- 当线程A执行for update,数据记录会锁住,其他线程执行到此行代码的时候需要等待A释放锁
- 事务提交时,for update 获取的锁会自动释放
- 缺点:如果业务比较耗时,并发情况下,后面的线程会长期处于等待状态,占用了很多线程,让这些线程处于无效的等待状态,如果大量线程由于获取for update锁处于等待状态,不利于系统并发操作。
4.乐观锁方式
使用数据库中的乐观锁实现。
4.1 接收到支付宝支付成功请求
4.2 查询订单信息
select * from t_order where order_id={trade_no};
4.3 判断订单锁是否已处理
4.4 如果订单已处理直接返回,若未处理,继续向下执行
4.5 打开本地事务
4.6 给用户加钱
4.7 将订单状态置为成功,这是重点
update t_order set status=1 where order_id={trade_no} and status=0;
// 执行上面的update操作会返回影响的行数num
if(num == 1){
// 更新成功
提交事务;
}else{
// 更新失败
回滚事务;
}
根据
where status=0
条件执行,同时多个线程执行这段代码,数据库内部会保证update同一条记录会排队执行,最终有一条update会执行成功,其余为失败的,num=0,然后根据num来进行提交或者回滚事务
延伸:
修改库存
update item set quantity=quantity-1 where id=1 and quantity-1 > 0;
5.唯一约束方式
使用数据库中的唯一约束来实现。
可以创建一个数据表
CREATE TABLE `t_uq_dipose` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`ref_type` varchar(32) NOT NULL DEFAULT '' COMMENT '关联对象类型',
`ref_id` varchar(64) NOT NULL DEFAULT '' COMMENT '关联对象id',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_1` (`ref_type`,`ref_id`) COMMENT '保证业务唯一性'
) ENGINE=InnoDB;
对于任何一个业务一个业务,有一个业务类型ref_type
,业务有一个全局唯一的订单号,业务来的时候,先查t_uq_dipose表中是否存在相关的记录,若不存在继续放行,流程如下:
5.1 接收到支付宝支付成功请求
5.2 查询t_uq_dipose
表,可以判断订单是否已处理
select * from t_uq_dipose where ref_type='充值订单' and ref_id={trade_no};
5.3 判断订单是否已处理
5.4 如果订单已处理直接返回,若未处理,继续向下执行
5.5 打开本地事务
5.6 给用户加钱
5.7 将订单状态置为成功
5.8 向t_uq_dipose
表插入数据,插入成功,提交本地事务,插入失败,回滚事务
try{
insert into t_uq_dipose(ref_type,ref_id) values('充值订单',{trade_no});
提交本地事务
} catch(Exception $e) {
回滚本地事务;
}
缺点:业务量大的时候
t_uq_dipose
表会成为瓶颈,需要考虑分表情况。
关于消息服务中,消费者保证消息处理的幂等性,同样参考以上方式实现(每条消息有一个唯一的消息id)
总结
- 实现幂等性常见的方式有:悲观锁(for update)、乐观锁、唯一约束
- 几种方式,按照最优排序: 乐观锁 > 唯一约束 > 悲观锁
- 如何选择:在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了。
1️⃣响应效率:如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁。乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。
2️⃣冲突频率:如果冲突频率非常高,建议采用悲观锁,保证成功率。冲突频率大,选择乐观锁会需要多次重试才能成功,代价比较大。
3️⃣重试代价:如果重试代价大,建议采用悲观锁。悲观锁依赖数据库锁,效率低。更新失败的概率比较低。
4️⃣乐观锁如果有人在你之前更新了,你的更新应当是被拒绝的,可以让用户从新操作。悲观锁则会等待前一个更新完成。这也是区别。
随着互联网三高架构(高并发、高性能、高可用)的提出,悲观锁已经越来越少的被应用到生产环境中了,尤其是并发量比较大的业务场景。