使用分布式锁时考虑哪些问题

工作中经常会遇到争抢共享资源的场景,比如用户抢购秒杀商品,如果不对商品库存进行保护,可能会造成超卖的情况。超卖现象在售卖火车票的场景下更加明显,两个人购买到同一天同一辆列车,相同座位的情况是不允许出现的。交易系统中的退款同样如此,由于网络延迟和重复提交极端时间差的情况下,可能会造成同一个用户重复的退款请求。以上无论是超卖,还是重复退款,都是没有对需要保护的资源或业务进行完善的保护而造成的,从设计方面一定要避免这种情况的发生。

本文以退款交易场景入手,引入分布式锁,尝试分析分布式锁需要考虑关注点,包括以下内容:

  • 锁的引入和局限性
  • 分布式锁的三要素
  • 分布式锁进阶
    • 获取锁操作的原子性
    • 锁与保护共享资源的数据一致性
    • 分布式锁的性能
    • 可重入
    • 公平锁和非公平锁
  • 分布式锁的容错,使用分布式锁时注意考虑哪些问题

锁的引入和局限性

锁是一种控制共享资源争抢的机制,采用互斥方式防止多线程(或多进程)间造成的冲突。锁是一种获取保护资源的凭证,就像公园门票,只有持有门票才有资格入园;锁是使得对同一类共享资源的访问串行化。没有获得锁只能排队等待,直到其他线程释放掉锁。这里需要对“同一类共享资源”正确理解,比如订单系统中的同一种商品库存,退款系统中同一个用户。

在多线程中,Java 已经提供了很好原生锁(包括synchronized,lock),前面的其他文章中也已经讲到了内置锁和显示锁的理解和使用,在此不再赘述。但是(是不是已经料到了我要说但是了呢?),在分布式系统中,因为要跨进程或者跨服务器 ,这种场景下JDK原生锁已经无法满足我们的需求,需要一种能够分布式系统中保护共享资源的方式,分布式锁在这种情况下产生了。

很多事情往往都是如此,为了解决一个问题,引入了新方案,而新方案却会带来其他的问题,又需要用更多的时间去解决新方案带来的问题。没有一个完美的方案,因此对方案的取舍,就是具体场景中应该重点关注哪些问题,忽略哪些问题的选择。

分布式锁的三要素

分布式锁是一个在分布式环境中很重要的原语,它表明不同进程间采用互斥的方式操作共享资源。如何才称得上分布式锁呢?分布式锁需要满足三个基本的条件:

  1. 外部存储
  2. 全局唯一标识
  3. 至少有两种状态,获取和释放
  • 外部存储
    顾名思义,分布式锁是在分布式部署环境中给多个主机提供锁服务。Java具有天生的多线程优势,在同一个进程的线程中可以通过互斥锁住共享资源来保证多线程之间干扰,锁的载体是堆中共享变量,使用JDK原生锁synchronized和lock可以很方便的解决,但是将问题扩展到分布式环境中,就超出了JDK原生锁作用范畴。需要另外的存储载体,可以是共享内存或者磁盘文件。考虑到分布式锁的高可用性,避免单点问题,因此共享内存中数据是需要持久化的,这点内容会在下文中的分布式锁的高可用中涉及到。

  • 全局唯一标识
    与JDK原生锁类似,分布式锁同样需要标记为全局唯一。在多线程环境中,锁可以使一个对象引用,也可以是基本类型变量,都有唯一的标识来区分锁保护的不同资源。仍然以上面的退款为例,为了保护用户的账户资金,不允许同一个用户并发退款。因此同一个用户退款操作采用互斥锁保护起来,不同用户之间不需要互斥操作。具体方法一种可以通过锁用户账户的方式,另一种对用户userId设置不同的状态标识,这两种方式都是采用对堆中变量的原子操作保证互斥的。
    分布式环境中上述第一种方法就不适用了,举个例子,小明的账户可以同时在A、B两个不同实例中加锁。那么可以采用第二种方法,自定义一个标识,使其全局唯一即可,每次申请退款时,首先尝试获取该标识,如果该标识已经被其他占用,则需要等待,直到释放该标识(是不是与synchronized很相似)。对于交易而言,全局唯一的标识很简单:业务+userId即可唯一标识。

  • 至少有两种状态
    锁至少需要两种状态:加锁(lock)和解锁(unlock)。用状态区分当前尝试获取的锁是否已经被其他操作占用,被占用只有等待锁释放后才能尝试获取锁并加锁,保护共享资源。

分布式锁进阶

为解决共享资源在分布式环境下并发访问带来的问题,引入分布式锁采用互斥访问的方式将并发访问串行化。下文中以Redis为例,分析使用分布式锁时重点需要考虑的情况。

  • 获取锁操作的原子性
    从读取锁的状态,到设置锁状态为加锁(获取锁的过程),不是原子性的操作,如果不能保证这两步作为一个的原子操作,可能存在竞态条件,在极端的时间差的情况下,会有多个服务同时获取到同一个锁,从而获取操作工作资源的凭证,这是不允许的。幸运的是Redis提供了CAS原子性功能SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置。

  • 锁与保护共享资源的数据一致性
    获取锁与开始操作共享资源必须保证一致性,结束操作共享资源和释放锁必须保证一致性。共享资源操作结束后必须释放锁,退出临界区,否则会造成锁饥饿;开始操作共享资源,必须是在获取锁之后,否则锁就无法保护共享资源。

  • 分布式锁的性能
    分布式锁需要考虑网络传输时间,超时时间同样需要考虑网络时间消耗。

  • 可重入
    某个请求试图获得一个已经由它自己持有的锁,那么这个请求就会成功,这是重入。当重入时需要将计数器加一,释放锁时,计数器相应减一,一般分布式锁同样支持可重入,因此需要设计标记不同的请求。

  • 公平锁和非公平锁
    公平锁设定按照请求的顺序获取锁,不允许插队。公平是个好东西,不过大多数情况下非公平锁的性能要高于公平锁。

分布式锁的容错

正常情况下,加锁,执行保护资源,释放锁。如果没有异常,那这世界就太美好了。那么生产环境中,使用分布式锁时应该注意哪些容错的问题。

  • 锁无法释放
    以退款为例,退款服务宕机,分布式锁服务正常。此时锁保护的资源(或部分)已无法对外提供服务,无法通知锁自身运行情况,为避免锁服务一直无法释放,可以为锁设置超时时间,当锁执行时间超过了超时时间,锁会过期,从而保证锁与保护服务的最终一致性。
    当然锁设置超时时间又会引出另一个问题:比如锁的超时时间是500ms,而部分退款服务可能由于网络等原因执行时间为800ms(退款服务没有宕机,仅仅是执行时间相比平均执行时间较长而已),这种情况下,锁已经过期,而退款服务仍在执行,锁作为保护资源的功能失效了。有一个办法可以兼顾超时时间和锁失效的问题,退款服务保持心跳通知锁服务,锁服务收到心跳后延长锁的超时时间,不足在于即使退款服务已经宕机,锁服务仍然需要到达超时时间后才会解锁。Redisson分布式锁就是采用这种方式。
    分布式锁时效设置的必要性:确保在未来的一定时间内,无论获得锁的节点发生了什么问题,最终锁都能被释放掉。

  • 性能
    针对访问量大的共享资源,尝试自旋方式获取锁时的长时间等待,即容易造成CPU空转性能的消耗,又容易造成节点阻塞;而每隔一段时间尝试获取锁,便无法保证资源的高效利用。基于以上两种解决方案的弊端,可以采用尝试获取锁一定次数后,加入到等待队列中,当锁释放后,通知等待队列中的下一个等待节点获取锁。既可以避免CPU空转带来的性能消耗,又可以及时响应,保证系统的性能和稳定性,避免毛刺的出现。设计上可以参考Java并发包中AQS。

  • 锁饥饿
    一个线程在尝试获取锁的过程中一直无法获取锁,这种情况就是锁饥饿,比如体弱的狼很难在一群强壮的狼群中抢到食物,很多情况下锁饥饿是由于优先级较低造成的。发生锁饥饿时,无法获取锁便无法进行锁保护资源的操作。为避免锁饥饿情况的发生,设计时需要将锁设计成公平锁。

  • 监控
    监听锁的运行情况,掌握锁持有者的动态,若判断锁持有者处于不活动状态,要能够强制释放其持有的锁,引入第三方监控系统。

当然,分布式锁还有一些其他的问题:比如频繁获取锁释放锁带来的系统稳定和性能问题,如何保证锁的高可用,分布式锁的持久化,分布式锁单点问题,分布式锁网络传输性能等,还有分布式锁主节点宕机,从节点还没同步到锁,锁的唯一性被破坏,多个客户端可以获得同一个锁…

写作不易,痛并快乐着;理解可能存在偏差,句句斟酌推敲;抵制抄袭,践行原创技术之路。如果本文能对您有所帮助,实为荣幸,我是葛一凡。
微信公众号

参考

  1. 分布式系统互斥性与幂等性问题的分析与解决
  2. 理解锁以及分布式锁
  3. 分布式锁的几种实现方式
  4. 分布式锁设计
  5. 聊一聊分布式锁的设计
  6. Redisson 分布式锁实现分析
  7. 分布锁——redis实现
  8. 分布式锁的实现
  9. 分布式锁总结
  10. Distributed locks with Redis
  11. 基于redis的分布式锁 RedissonLock实现分析
  12. How to do distributed locking
  13. 基于Redis实现分布式锁,Redisson使用及源码分析
  14. redis在学生抢房应用中的实践总结
  15. Redisson 分布式锁实现分析 #4
  16. 饥饿和公平