当前位置:首页 > 问答 > 正文

并发情况下幂等难题怎么破?分布式锁那些事儿聊聊

说到并发情况下的幂等难题和分布式锁,这确实是后端开发中经常要碰到的硬骨头,咱们就用人话,像聊天一样,把这事儿掰扯清楚。

第一部分:啥是幂等?为啥并发下它就成难题了?

想象一个场景:你用一个手机App支付订单,点了“付款”按钮,可能因为网络卡,你没立刻看到反应,于是你又心急地点了好几下,这个时候,你希望发生什么?

你肯定不希望因为点了五下,就真的扣你五笔钱,你心里期望的是:无论我点多少次,只要订单是那个订单,最终只成功支付一次。 这种“一次和多次请求产生的结果完全相同”的特性,幂等性”。

(来源:在HTTP协议中,GET、PUT、DELETE等方法被定义为幂等的,而POST通常不是,这为我们设计API提供了理论基础。)

在单台服务器的简单情况下,实现幂等可能还比较简单,在处理请求前,先在数据库里查一下这个订单是不是已经处理过了,如果处理过了,就直接返回成功,不再执行扣款。

但问题就出在“并发”上,现在系统基本都是分布式的,同一个服务可能有好几个实例在同时运行,当你的五次点击,通过负载均衡器被几乎同时发送到了后端的五个服务实例上,会发生什么?

这五个服务实例可能会在同一时刻去数据库查询同一个订单的状态,而此刻,这个订单还是“待支付”状态,五个实例都认为:“哦,这个订单还没处理,我来处理吧!”然后几乎同时发起了五次扣款操作,悲剧就这么发生了。

这就是并发下的幂等难题:在多个请求同时到达时,传统的“先查后改”逻辑会瞬间失效,因为“查”这个动作在并发的瞬间得到的结果是一样的,无法起到控制作用。

第二部分:分布式锁登场,它怎么就成了“救火队长”?

为了解决上面这个“同时查、同时改”的问题,我们急需一个机制,能保证在分布式环境下,对同一个资源(比如那个订单ID),在同一时间只有一个服务实例能够进行操作,这个机制,就是分布式锁。

它的核心思想很像现实生活中的一把钥匙,比如一个公共卫生间,只有一把钥匙,谁想上厕所,必须先拿到这把钥匙,用完之后再还回去,其他人要想用,必须等着。

并发情况下幂等难题怎么破?分布式锁那些事儿聊聊

分布式锁也是这个道理:

  1. 加锁:当第一个请求(服务实例A)要来处理订单123时,它先去一个所有实例都能访问的“公共地方”(比如Redis或Zookeeper)说:“我要给订单123加锁”,如果加锁成功,它就拿到了操作权。
  2. 执行业务:实例A安心地去执行查数据库、扣款等一系列业务逻辑。
  3. 释放锁:操作完成后,实例A把订单123的锁释放掉。
  4. 等待与获取:在实例A持有锁的期间,如果实例B也想来处理订单123,它尝试加锁时会失败,因为它发现锁已经被A拿走了,这时候,实例B可以选择直接返回“请勿重复操作”给用户,或者在一旁等待,直到A释放锁后再去获取。

通过这把“锁”,我们强制让对订单123的并发请求变成了“排队执行”,从而保证了幂等性。

(来源:分布式锁是实现并发控制的一种常用技术,其实现方式多样,如基于数据库、Redis、Zookeeper等,每种方式各有优劣。)

第三部分:分布式锁也不是“银弹”,它有自己的烦心事

听起来分布式锁很完美?但用了它,你就会遇到一系列新问题,这些问题甚至比幂等问题本身更棘手:

  1. 锁超时问题:如果实例A加锁后,在执行业务代码时卡住了(比如发生了Full GC垃圾回收),或者干脆宕机了,导致它永远没有释放锁,那订单123这把锁就永远被它占着,其他实例再也处理不了这个订单了,这就是“死锁”,为了解决死锁,我们一般会给锁设置一个“超时时间”,但超时时间又引出了新问题……

    并发情况下幂等难题怎么破?分布式锁那些事儿聊聊

  2. 锁误释放问题:假设实例A因为某些原因执行得很慢,超过了锁的超时时间,这时锁被系统自动释放了,实例B以为没人用锁了,就拿到了锁并开始处理业务,紧接着,缓过劲来的实例A执行完了,它依然会去执行释放锁的操作,这一下,就把实例B正在用的锁给释放了!可能导致第三个实例C趁虚而入,造成混乱。

  3. 集群环境下的可靠性:比如你用Redis做分布式锁,如果你只用了单机Redis,它一旦宕机,所有锁就失效了,于是大家会用Redis集群,但Redis集群的主从同步是异步的,有可能出现:实例A在主节点上加锁成功,但主节点在把锁信息同步给从节点之前就宕机了,然后从节点升级为主节点,这个新主节点上并没有刚才的锁信息,实例B又能成功加锁了,这就破坏了锁的“互斥性”。

(来源:Redis官方文档中提到的Redlock算法,就是为了尝试解决在分布式Redis环境下实现更安全的锁,但该算法本身也引发了业界的广泛讨论和争议,比如Martin Kleppmann的文章就曾指出其潜在问题。)

第四部分:除了锁,还有别的招吗?

分布式锁很强大,但复杂度高,有时候我们可以用一些更轻量级的方法来保证幂等,特别是在特定场景下:

  • 数据库唯一约束:这是最简单有效的一招,我们可以生成一个全局唯一的请求ID(比如UUID),在处理业务前,先把这个请求ID往数据库里一张表里插,利用数据库的唯一索引约束,如果重复插入相同的请求ID,数据库会直接报错,这样,后续的重复请求在第一步就会被挡住,这种方法把并发控制的压力转移给了数据库,实现起来非常简洁。

  • 状态机机制:很多业务数据本身就有状态流转,比如订单状态是“待支付”->“已支付”->“已发货”,我们可以在更新数据库时,加上状态条件,扣款成功的SQL语句可以写成:update orders set status='已支付' where order_id=123 and status='待支付',这样,即使多个请求同时执行这个SQL,数据库也会保证最终只有一个更新成功(通过行锁等机制),其他请求会发现影响行数为0,就知道操作重复了。

解决并发幂等,分布式锁是一个重要的工具,但它是一把“重剑”,用起来要小心,要注意超时、可靠性等坑,在设计系统时,不妨先看看能否用“数据库唯一约束”或“状态机”这种更简单轻量的方式来化解,如果不行,再考虑举起分布式锁这把重剑。