跳至主要內容

实现幂等的方式

zhengcog...大约 5 分钟博文幂等

背景

并发情况下,对于同一笔业务操作,不管调用多少次,得到的结果都应该是一致的,主要针对数据写操作。

举例场景

以支付宝充值为例子

解决方式

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 提交本地事务

  1. 当线程A执行for update,数据记录会锁住,其他线程执行到此行代码的时候需要等待A释放锁
  2. 事务提交时,for update 获取的锁会自动释放
  3. 缺点:如果业务比较耗时,并发情况下,后面的线程会长期处于等待状态,占用了很多线程,让这些线程处于无效的等待状态,如果大量线程由于获取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)

总结

  1. 实现幂等性常见的方式有:悲观锁(for update)、乐观锁、唯一约束
  2. 几种方式,按照最优排序: 乐观锁 > 唯一约束 > 悲观锁
  3. 如何选择:在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了。
    1️⃣响应效率:如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁。乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。
    2️⃣冲突频率:如果冲突频率非常高,建议采用悲观锁,保证成功率。冲突频率大,选择乐观锁会需要多次重试才能成功,代价比较大。
    3️⃣重试代价:如果重试代价大,建议采用悲观锁。悲观锁依赖数据库锁,效率低。更新失败的概率比较低。
    4️⃣乐观锁如果有人在你之前更新了,你的更新应当是被拒绝的,可以让用户从新操作。悲观锁则会等待前一个更新完成。这也是区别。

随着互联网三高架构(高并发、高性能、高可用)的提出,悲观锁已经越来越少的被应用到生产环境中了,尤其是并发量比较大的业务场景。

上次编辑于:
贡献者: Hyman
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.5