置顶说明
严格来说,所谓人云亦云的接口幂等性,大部分场景是要求接口防重或数据幂等,而不是接口幂等,很多人都搞混了。
举例:后端做了支付防重,用户对单一订单重复支付,再次支付不是提示支付成功(接口幂等是要求多次请求返回的结果一致),而是提示请勿重复支付。
很多时候是防重是保证MySQL表数据的幂等,而不是接口幂等。
接口幂等与接口防重
- 接口幂等:对于一个接口进行多次请求,服务器响应的结果一致。
- 接口防重:对于一个接口进行多次请求,服务器不会产生额外的副作用,或产生业务逻辑意料之外的情况。
常规方案
前端实现方案(多用户防重)
耳熟能详的防抖:用户点击按钮后,可将按钮置灰几秒钟,一方面提示用户不能点了,一方面只让接口请求了一次,减轻服务器的压力。
验证码方案(常见于抢购的流量削峰)(多用户防重)
秒杀场景需要极致的性能优化,秒杀开始时的抢购按钮点击后,添加验证码功能,不同用户输入速度不一样。
一方面用于流量削峰,防止服务端瞬时负载过大挂掉(报502等)。
一方面可以防止用户狂点,影响接口幂等性,只要点按钮就蹦出来验证码。
但是这里小心有业务逻辑安全漏洞,验证码是否正确或被绕过(黑客直接请求下单接口),都需要与下游的业务逻辑保持线性关联。
低并发时,数据库的判断问题(单用户防重)
低并发也不要小看,有时就会阴沟里翻船。
我之前写过,邮政银行的内部员工营销项目(用的PostgreSQL数据库),当时考虑到请求量不大,于是就没用redis去抗。
模糊逻辑如下:表中查不到部分数据就新增,查到数据就提示,当时的业务场景还加不了唯一索引。
但是出现了相同数据的情况。
后来一推理,是同一个人狂点(存在表中的create_at字段时间相同),此时多个请求都没有查询到表中有这个数据,就是所谓的趁PgSQL不注意,于是同一组数据都进行了新增操作,出现了bug。
也很好解决:
在用户写操作成功逻辑代码区的下游中,添加,用redis的setex命令,将模块名拼接用户id作为key,设置3秒过期,1作为value,用不上value,所以随便尝试。
上游代码:只要检测到有值,则给提示。
因为3秒的时间,足够数据库的insert操作了,还不用手动删除这个key。
伪代码如下:
if(Redis::exists('模块名:' . $user_id)) {
return '操作频繁,请勿重复操作';
}
if(判断表中是否存在数据sql) {
return '您已提交,请明天再来';
}
写操作SQL,将数据入库操作...
if(写操作SQL执行失败) {
return '操作失败,请稍后重试';
}
Redis::setex('模块名:' . $user_id, 3, 1);
return '操作成功';
基于请求头数据(Token)的前置判断方案(单用户防重)
这种方式,适用于快速解决并全局解决幂等性的项目,高明手段。
但是覆盖率太广,需要根据请求的url,添加黑白名单的策略,就是说哪些接口要防重,哪些接口不能防重。
- 在不用登录的业务场景下防重:
请求头可以获取,客户端的IP、UA数据,两者结合,基本可以区分不同用户,但是有误差。
在提交接口时,让前端生成一个随机字符串,并保存到LocalStorage,跟随接口提交,后端无需验证字符串,但3者一结合,基本能确定是一个用户。
将3者拼接后计算hash散列值(省空间)作为redis的key
将用户提交的数据的hash散列值(省空间)作为key对应的value。
将redis过期时间设置为3秒。
若用户重复提交就能检测出这个值,并且3秒过期,不用维护这块的数据。
伪代码如下
if(当前请求的接口需要防重) {
$server = $_SERVER;
$user_temp_key = md5($server['REMOTE_ADDR'] . $server['User-Agent'] . $server['HTTP_RAND_STR']);
$user_temp_value = md5($_POST['post_data']);
$cache = Redis::get($user_temp_key)
if($cache && ($cache === $user_temp_value)) {
return '操作频繁';
}
Redis::setex($user_temp_key, 3, $user_temp_value);
}
- 在用登录的业务场景下防重:
有用户的令牌,接口能获取到,在项目中的前置中间件添加类似以上的逻辑,把redis的key换成token即可,也是一种方案。
数据库唯一索引兜底方案(多用户防重)
添加唯一索引做兜底,就算并发绕过了业务逻辑,但使用会在唯一索引那里报错,然后返回给用户此次操作失败,从而保证接口幂等。
缺点是有些场景不能加唯一索引。
状态机判断方案(单用户防重)
例如订单状态,可能是1手动取消订单、2被动取消订单、3待支付,4待发货,5已发货,6代签收,7待评价,5已评价。
状态机的更新,如果不是递增的、不连续的、或者不变,也有可能是并发过来,或者是黑客攻击。
也可以在这一步做一些验证。
在支付回调等场景,根据订单状态的判断,在防止重复改状态,或者防止变更为不符合事务发展规律的状态时,很 重要。
高并发的方案
MySQL 可重复读的隔离别引发的幻读问题
场景:有些操作需要insert的事务,请求A中的事务a还未提交,此时又过来一个请求B,也就有了事务b,两者算是相同的数据进行insert,表中添加了唯一索引。
分析:为了保证防重,事务b insert时需要先查询有没有相同的数据,如果没有再进行插入,此时事务a还没有提交,事务b也就查询不到数据(能查到就是脏读,MySQL RR的隔离级别不会出现),于是进行了inset操作,结果导致事务b被阻塞(受事务a的行级X锁排斥),等事务a提交后,事务b插入失败。
幻读:同一个事务里前后查询两次相同范围的数据,后一次查询查询到了前一次看不到的东西,这叫幻读。MySQL的机制,select没办法直接幻读,只能通过insert 插入相同的数据,达到唯一索引冲突的错误来证明。
解决:幻读的问题可以通过间隙锁或临键锁去阻塞,但是无法解决唯一约束冲突的报错问题。
唯一约束冲突的问题,看业务也是一项,如果重复是小概率事件,可以忽略。
如果概率挺大,尽量不要让MySQL频繁报错,添加一个redis组件,在上次事务提交成功后,缓存提交数据的md5的值,与这次提交数据的md5的做个对比,如果一致,说明有重复,避免了并发情况下,下游唯一约束冲突的报错问题。
用空间换时间的方式。这样可以把问题引到上游,减轻MySQL服务器的压力,和报错数量。提升性
能。
可按照以下伪代码思路去优化(注意是优化,不是解决)
$post_data = 'md5加密后的接口数据';
$cache_data = Redis::get('key');
if(($cache_data != null) && ($post_data === $cache_data) ) {
return '请勿重复提交';
}
查询是否存在的防重提交SQL... //这一步是数据库防重的兜底策略。
if(有重复) {
return '请勿重复提交';
}
事务sql...
if(事务回滚) {
return '操作失败,请稍后重试';
}
if(事务提交) {
Redis::setex('key', 3, 'md5加密提交的数据'); //3秒后过期,不用考虑占空间和维护问题。
}
return '操作成功';
扩展:MySQL事务(4种事务隔离级别、脏写、脏读、不可重复读、幻读、当前读、快照读、MVCC、事务指标监控)
分布式锁(多用户防重)
分布式锁对于PHP而言,不常用。用的相对没有Java的多,并且PHP实现分布式锁缺少了一些机制,显得鸡肋。
用分布式锁,也可以解决上面的问题,但是会降低性能。
除非因为重复插入的报错非常多,否则不推荐用。
但是有几点要注意:
- 一定要在事务提交后在释放分布式锁,如果在否则事务提交前释放,那其它请求就可以拿到分布式锁,进而提交事务,仍旧可能遇到上面一样的问题。
- 不要让事务包含分布式锁,否则事务因为分布式锁的阻塞,而阻塞当前事务。其它事务过来也会因为这个问题,阻塞到那里占用MySQL连接资源。应当反过来,分布式锁包含事务。
- 不要锁错对象了,分布式锁锁的是事务,是查询。
悲观锁(多用户防重)
- 原理:就是同一时间,MySQL只允许一个写请求改某个部位的数据。
- 补充:加X锁获得最新的数据,防止被改动,然后去更新它,其它的请求被阻塞(等待)。
- 注意:加锁最好根据主键或者唯一索引列,避免锁住更多的数据,要降低锁的粒度。并且有性能问题。
请阅读之前写过的文章:
MySQL锁(读锁、共享锁、写锁、S锁、排它锁、独占锁、X锁、表锁、意向锁、自增锁、MDL锁、RL锁、GL锁、NKL锁、插入意向锁、间隙锁、页锁、悲观锁、乐观锁、隐式锁、显示锁、全局锁、死锁)
乐观锁(多用户)
- 原理:先查询出版本号,并将版本号作为where条件,联合其它where条件去更新(并更新版本字段),如果受影响函数为0,就说明数据没被改动,需要再次查询后更新,如此往复,直到有重试次数的干预,或者受影响行数>0。
- 补充:如果select语句,查不到数据,可能是数据被删除了,后面的update也就没必要执行了。
- 注意:谨防乐观锁的ABA问题。至于update受影响行数为0,是否正常返回或者重试,看业务。
请阅读之前写过的文章。
MySQL乐观锁与悲观锁