前言
作为一个开发者,在实现业务功能时, 要尽可能考虑周全,这样才可以尽量减少bug,减少技术债的产生。在编码时,有些坑是未知的, 有些坑是已知的。对于未知的,那只能是踩到了再说,对于已知的,那就要做到 不去踩坑。
要做到 ---> 不踩坑,首先我们要知道坑是什么。 下面我简单列一下平时开发常见的:
- 并发时
数据安全性
- 涉及同步数据时
数据一致性
- 操作db(多个写操作)时 可能部分成功部分失败的
脏数据
问题 - 多次同样的请求,结果不一样,即
幂等问题
重复提交
造成的重复数据- 内部/外部 对接时的失败
重试机制
接口超时
程序/db 死锁
GC 频繁/内存泄露/内存溢出
慢查询
- 如何 限流
- 高并发时,服务是否能抗住
- 服务不可用时的处理方案 熔断降级
- 缓存(穿透/雪崩/击穿)
- 接口安全性
- .............. 等等等等
其他需要注意的太多了我们就不再赘述。本文将围绕 幂等性
展开,其余的我们不在本文讨论。
1、 幂等
1.1、什么是幂等?
说起幂等可能很多开发者都熟悉这个东西。我们用通俗的语言简单介绍一下
幂等就是同一个操作,不论执行多少次,产生的效果和返回的结果都是一样的
对应到接口中,也就变成了:
同样的请求, 接口 执行/并发执行 n次,产生的效果和返回的结果都是一样的
实际中,以下场景很容易出现幂等问题:
- 用户手抖
- app端上重试/from表单多次提交
- 第三方重试调用我们时重试
- rpc重试
- mq消费重试
看的出来,幂等很好理解,没什么可说的。 比如用户下单
,扣款
,扣库存
等等业务场景,都需要保证幂等性
,不能说下两个一模一样的单
,或者说给用户多扣款/扣库存
,也不能少扣,多扣用户打你,少扣老板打你是不是???
1.2、如何保证幂等性?
1.2.1、insert、update、select、delete操作的幂等性
在实际开发时,一般的业务场景归结起来就是crud (增删改查),而一般
select操作: 具有天然的幂等性,因为他不修改数据。
insert 操作: 不具备幂等性(假设没做幂等处理情况下)
,比如我 短时间/或者说并发调用, 用相同的参数调用下单接口n次(此时程序执行了n遍下边的insert语句),那可能数据库中就存在相同的订单信息。
sql
假设下单逻辑如下伪代码,很简单,简单到无脑(当然实际肯定不是这样的,我们只是举例为了模拟这种 未处理幂等性的情况)
//1. 必填参数校验
//2. 创建订单
//3. 发送mq消息,执行其他逻辑处理(如:扣库存)
INSERT INTO order ( orderId, channelId,orderPrice, status, createTime, updateTime)
VALUES ( 20230905001, '3012', '3000', 1, '2023-09-05 20:09:15', '2023-09-05 20:09:15');
update操作: 如果是这样的sql:update order set orderPrice = 20 where orderId =20230905001;
则具备幂等,因为要set的值是固定的,多次执行也是一样的结果。而如果是这样:update order set orderPrice = 20 + oldOrderPrice where orderId =20230905001;
则不具备幂等了, 因为要set的值,每次都可能不一样。所以不具备幂等性。
delete操作: 一般where条件是等值查询且只命中一条数据(比如 delete order where orderId = 20230905001;
)则具备幂等,因为多次执行的结果一样只删除了一条数据。 而如果条件是范围 比如: delete order where orderId> 20230905001;
则可能多次执行的结果是不一样(因为可能会删除后来新增的数据),则不具备幂等性。
1.2.2、保证幂等的方式
一般情况下,新增数据时,我们都要先根据某一个业务上的 唯一条件 去查询一下,没有数据才新增。
以 下单业务 为例:
1、 先查后写(高并发下不能保证 幂等)
- 参数校验
- 根据生成的订单号查询订单: (
select * from order where orderId = 20230905001;
) - 有订单,返回。无订单,创建订单并继续下边流程。
这种情况我们一眼就可以看出来,步骤 2和3 不具备 原子性 ,所以在并发情况下,仍然会有重复的订单数据产生,也就是说这种方式实现不了幂等。
2、 使用悲观锁 保证幂等
- 参数校验
- 根据生成的订单号查询db: (
select * from order where orderId = 20230905001 for update;
) - 有订单,返回。无订单,创建订单并继续下边流程。
for update 在 记录存在 和 不存在 时候加的锁不一样,所以我们简单讨论一下。
小前提:假设我们的数据库隔离级别是 REPEATABLE-READ
,并且没有关闭间隙锁。
首先:我们假设同一时刻有两个请求并且入参全部一样(请求a和请求b)
情况一: 假设db中
存在订单号 20230905001
,则请求a 通过for update 拿到的是行锁,此时请求b是阻塞的,直到请求a释放锁以后才能获取到该条记录的行锁(下一篇文章会亲自演示一下)。情况二: 假设db中
不存在订单号 20230905001
,则for update 其实是加的间隙锁
(这种情况下,如果有并发请求(请求a和请求b都是可以拿到间隙锁的就算是同一个间隙也是可以同时拿到间隙锁的,因为间隙锁是兼容的),但是在插入时,会先拿到插入意向锁(Insert Intention Lock),这样就会造成死锁,db会检测到并且将其中一个事务回滚,从而另外一个事务拿到insert插入意向锁,从而插入成功,这样从结果上看也可以保证幂等)。
for update 记录不存在 情况下 保证幂等的原理图示:
关于mysql检测到死锁以及如何出现死锁这个具体实操和死锁日志,我们不在本文过多演示了,会放到后边mysql死锁分析文章中。
总而言之: 我们可以看到悲观锁是可以实现幂等的
,但是代价有点高
,正所谓他是"悲观的" 所以每次都会去在db层面加锁
,并且可能上升为间隙锁(当记录不存在时,此时可能造成死锁)
或者表锁(如果列不是主键或者列上没有唯一索引的话)
那此时竞争将很大 (尤其是并发高的接口)所以 性能比较拉胯。如果有更好的选择,尽量还是不用这种方式了。
3、 使用乐观锁 保证幂等
乐观锁这种情况一般适用于update的幂等性,比如我们上边说的 update order set orderPrice = 20 + oldOrderPrice where orderId =20230905001;
这种更新场景,使用乐观锁比较合适。
具体方式就是在数据库增加一个version字段,新增时默认可以是1,每次更新时都+1。更新条件中带上未更新时(刚刚查出来的)的version。
//step:1 、根据订单id查询订单信息
sql
select orderId,orderPrice,version,xxx from order where orderId=20230905001;
假设查出来的version是1 ,并且结果集使用 dbResult对象保存 。
//step:2 、为空提前退出原则,直接返回。若不为空则使用:
sql
update order set orderPrice = dbResult.orderPrice+20,version = dbResult.version+1 where orderId = 20230905001
and version = dbResult.version;
语句来更新订单价格和version ,这样做为啥可以保证幂等呢? 因为请求a
查到的version等于1
,后续的update是可以的,在update之后, db中的version
变成2
了。(在请求a更新之前)这时如果 并发的 请求过来(假设请求b
查到的version也是1,事实上这种并发现象很多很常见 ),请求b
再执行相同的更新sql那么影响行数是0, 也就是where条件不成立更新了0行数据(从而实现了多次并发请求,只有一次影响了数据)的目的),我们画个示意图表达一下:
到此也就解释了乐观锁为啥可以保证幂等了,可以看到乐观锁并不是上来就加锁,而是到最后update那一步,才会去获取锁。乐观锁通在写时通过比对版本号 实现了并发操作时 的 幂等性。
4、 使用唯一索引 保证幂等
这个比较好理解,比如给订单表订单id字段(orderId)加上唯一索引(alter table order add UNIQUE KEY orderId_idx (orderId);
)后,重复下单的(订单号相同),肯定会报错了(Duplicate entry '20230905001' for key 'order.idx_orderId'
),这样通过数据库层面来保证了幂等性。也是一种办法,但是缺点比较明显,就是依赖数据库,如果并发高会有一定的性能瓶颈,另外这个只适用insert操作,并且还得捕获异常代码比较冗余。
5、分布式锁(redis,db,zk)
分布式锁在我们业务系统中比较常用,我们可以使用zk,db,或者redis来实现,如果通过db实现,那需要新建一个lock表也叫防重表反正怎么叫都行,都是一个意思 ,ddl如下:
sql
CREATE TABLE `db_lock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`orderId` varchar(200) NOT NULL COMMENT '订单id',
`createTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
unique KEY `idx_orderId` (`orderId`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT='lock表';
然后逻辑就和分布式锁的逻辑基本一致啦, 下单时拿着具有唯一性的值去插入,如果存在orderId那么自然会报唯一键冲突的错,发生异常代表该订单存在,直接返回(为啥非得建一个这个表?在订单表给订单id加上唯一索引不是也能实现吗? 原因就是这样把业务和幂等控制分开,这样这俩尽量解耦一下隔离性更好些)。
另外一种方式就是redis的 setnx 这个不多说了,分布式锁最常用的,
不管哪种分布式锁,目的都是保证某个接口(以下单接口为例)并发情况下 同样的订单(注意的是分布式锁大多需要配合select操作,防止锁过期后的重复下单请求 ),只有一条数据只执行一遍下单逻辑,用户手抖/端上重试/第三方重试/rpc重试/消费重试 这种情况还是很多的。
6、状态机幂等
很多时候我们的业务中都是有状态机的,比如订单有很多状态(匹配中,待出发,开始行程,行程中,行程结束,代付款,付款完成)
,并且这些状态都是不能乱序的,现实生活中就是,所以映射到程序上也是。这样我们在更新某个状态时,就可以拿 该状态前一个状态 当做where条件,以此来保证只更新一次从而实现幂等。
比如在 从待出发
变为 开始行程
时(开始行程前,状态必然也必须是待出发),我们会执行下边这个sql来变更状态
sql
update order set status = '开始行程' where orderId='20230905001' and status='待出发';
而如果有重试或者重复请求尝试再次更新为开始行程 的话,由于db中status
已经被第一个请求更新为开始行程
,所以where条件不符合
,也就不会修改数据了。从而也是实现了幂等。感觉这个好像和乐观锁有点类似。
7、token机制
请求下单接口前
,先请求下获取token接口获取成功set到redis
拿着token来请求下单接口,如果del删除返回1代表删除成功,继续下边流程,如果返回0代表没有这个token也就是说是不认可的请求,直接返回。这种机制我觉得似乎不太好,总感觉怪怪的,而且增加了网络开销,另外就是安全性也不太好,对于并发要求高的接口,感觉不使用顶多就是from表单这种似乎这种方式可以一试。
token机制图示:
2、总结:
保证系统的幂等性是很有必要的 ,尤其是比较重要的场景比如:下单,支付,扣款/扣库存 等,但是也带来一定的开销和开发成本 ,但是相比安全稳定来说那点开销真的算不上啥 。我个人觉得分布式锁+select 是一种比较好的保证幂等 的方式,我们业务中也很常用这种。总之不管是什么业务,都要在设计初尽量考虑周全 些,避免留下技术债,也为了我们能 过一个好周末,睡一个好觉!。不是吗???