场景化面试:关于分布式锁的十问十答
面试官问:在某次产品迭代中,产品经理提出一个新的需求,要求在用户生日当天上午十点发送祝福短信,你会如何实现这个功能?
候选人:这个需求是定时任务的典型场景,使用定时任务在指定的时间点去扫描出符合条件的用户列表,并循环调用发送短信的接口即可。
面试官:好,这个定时任务的服务起码会部署两个实例,以避免单点故障,那如何避免两个实例同时给同一个用户发送短信造成重复短信呢?
候选人:这个问题的本质是同一个时刻只能有一个实例运行定时任务,是典型的分布式锁场景。
面试官:那为什么需要分布式锁?
候选人:分布式锁其实是单机锁在分布式场景下的延伸,在说明为什么需要分布式锁之前,我先简单介绍下锁的概念,锁是操作系统的基本原语,它是用于并发控制的,能够确保在多 CPU 、多个线程的环境中,某一个时间点上,只能有一个线程进入临界区代码,从而保证临界区中操作数据的一致性;当把使用场景扩展到分布式环境中,也就是跨机器跨进程时,就是分布式锁,本质上是解决进程间访问临界区代码的问题,上面定时任务中要执行的发送短信的代码就是临界区代码。
面试官:那一个相对完备的分布式锁需要具备哪些特性?
候选人:实现一个分布式锁,首先要确定锁存放在哪里?对于单机锁我们可以使用内存中的一个整数的不同取值来表示加锁或者解锁的状态;对于分布式锁,由于这个锁需要被不同机器上的进程访问到,因此,一般是把锁存放在共享存储中,例如关系型数据库、分布式缓存等。确定锁的存放位置,接下来 就需要考虑分布式锁需要具备哪些核心特性,总结起来主要有:
互斥性:在分布式高并发条件下,同一时刻只有一个线程可以获得锁。
超时机制:锁服务和请求锁的服务分散在不同的机器上面,它们之间是通过网络来通信的,所以我们需要用超时机制,来避免获得锁的节点故障或者网络异常,导致它持有的锁不能释放,出现死锁的情况。
可重入性:一个节点的一个线程如果已经获得这把锁,那么这个线程在持有锁的期间,可以再次成功获取锁。
公平性:根据具体的实现,锁可以分为公平锁和非公平锁,假如目前有三个线程在竞争同意把锁,线程 A 成功获得锁,线程 B 和线程 C 没有获取到并阻塞等待 A 释放锁,且线程 B 先于线程 C 阻塞等待,那么在线程 A 释放锁后,这把锁会被等待时间最长的线程 B 获得,按照先来先得的原则,那么这把锁就是公平锁,反之就是非公平锁。
完备的锁接口:即锁的接口定义中,加锁操作要同时提供阻塞式接口 lock 和非阻塞式接口 tryLock,解锁操作要提供 release 接口。
面试官:对于上面说到的超时机制,如果持有锁的节点处理临界区的代码比较耗时,所需时间大于锁的超时时间,这时会出现临界区代码还没有处理完这把锁就被释放掉了,最终导致其他节点可以获取这把锁并执行临界区代码,导致互斥失效的问题,如何解决呢?
候选人:这个问题可以通过锁续约来解决,也就是通过另外一个线程通过心跳机制来不断延长锁的超时时间。
面试官:好,那锁的重入性具体怎么实现?
候选人:由于我们是实现同一个线程可以重复获取一把锁,因此,在加锁成功后,我们要记录下获得这把锁的节点 id+线程 id,将它俩的组合作为唯一标识和这把锁绑定;并在加锁逻辑执行之前,增加一个判断,如果当前请求的节点 id+线程 id 和当前持有锁的相同,那么直接返回成功即可,否则执行正常的加锁逻辑。
面试官:分布式锁的实现方法有哪几种?
候选人:分布式锁有三种主流实现方法,分别是:
基于关系型数据库(例如 MySQL):创建一张表用于记录共享资源信息,对临界资源做唯一性约束,通过增加一条记录来对某个资源加锁,通过删除记录释放锁。
基于分布式缓存 Redis :通过调用 Redis 函数 SETNX+EXPIRE 实现,同时为了保证原子性,可以通过 Lua 脚本来实现锁的设置和过期时间的原子性。在 Redis 2.6.12 版本后 SETNX 增加了过期时间参数,也可以直接使用这个重载方法。SETNX 方法返回 1 表示获得 key 所代表的锁,返回 0 表示获取锁失败
基于分布式协调服务 ZooKeeper :在对应的持久节点 shared_lock 的目录下为每个进程创建一个临时顺序节点,然后查看哪个进程对应的节点编号最小,最小说明式最先创建的,因此获得锁,否则,等待最小编号节点释放锁。
面试官:那这三种实现方式的优缺点、使用场景如何?
候选人:数据库实现方式优点是实现简单,缺点是容易出现单点故障,死锁问题,而且性能和可靠性低;Redis 实现方式优点是性能高,可以跨集群部署,无单点故障问题;缺点是锁失效时间控制不稳定,可靠性不如基于 ZooKeeper 方式实现高;ZooKeeper 方式优点是没有单点故障、死锁问题,可靠性高;缺点是性能没有 Redis 方式高。从使用场景上看,数据库方式适合系统并发量不大且对性能要求不高的场景;Redis 方式适合高并发和对性能要求高的场景;ZooKeeper 方式适用于大部分场景(除了对性能要求极高的场景)。
面试官:如果是在 Redis 集群环境下,由于 Redis 集群数据同步到各个节点时是异步的,如果在 Master 节点获取到锁后,在还没有同步到其它节点时,Master 节点崩溃了,此时新选举出来的 Master 节点依然可以获取锁,就会导致多个应用实例可以同时获取到锁,锁的互斥性失效,这种问题如何解决?
候选人:确实会存在这种情况,因此,一般基于 Redis 集群实现的分布式锁我们建议使用 RedLock 算法,开源 Reddison 函数库实现了这个算法。在不同的节点上使用单个实例获取锁的方式去获得锁,且每次获取锁都有超时时间,如果请求超时,则认为该 Redis 节点不可用。当应用服务成功获取锁的 Redis 节点超过半数(N/2+1,N 为节点数) 时,并且获取锁消耗的实际时间不超过锁的过期时间,则获取锁成功。一旦获取锁成功,就会重新计算释放锁的时间,该时间是由原来释放锁的时间减去获取锁所消耗的时间;而如果获取锁失败,客户端依然会释放获取锁成功的节点。
面试官:分布式锁的使用场景,除了我们上面说到的定时任务,还有哪些常见的使用场景?
候选人:在秒杀活动中,为了防止库存超卖时可以使用。
旁白:关于分布式锁,有一个不错的开源实现,lock4j,基于 Spring AOP 的声明式和编程式分布式锁,支持 RedisTemplate、Redisson、Zookeeper 等,其他分布式锁实现也可以自己扩展。
参考资料
关于我
微信公众号:面试官问,原创高质量面试题,始于面试题,但不止于面试题。
版权声明: 本文为 InfoQ 作者【面试官问】的原创文章。
原文链接:【http://xie.infoq.cn/article/f6d1e6be3f835578897de9696】。文章转载请联系作者。
评论