分布式锁的实现-mysql&redis

曾经我在一场真实面试中被问到分布式锁的实现,只能简单回答上来根据redis的setnx去做,而不知这里面其实还有很多坑.也不知是否还有其他方式.面试结束后就有了下文.

分布式锁

在单进程环境中,当多个线程产生资源竞争时.程序员们会采用 Synchronize 或 Lock 去对临界资源进行加锁,使得在修改临界资源时,能够线性执行以消除并发.那么由于多线程是挂在同一个进程下的,我们就必须在这个进程内设置一个标记变量,来让所有线程都可以发现.
而在分布式环境下,多进程可能会产生资源竞争,那么此时的锁就应该存放在多进程都能够看到的地方.由此我们想到了两种方式:数据库和redis.

基于数据库

基于数据库的锁实现也有两种方式,一是基于数据库表,另一种是基于数据库排他锁。

基于数据库表的增删

基于数据库表增删是最简单的方式,首先创建一张锁的表主要包含下列字段:方法名,时间戳等字段。

具体使用的方法,当需要锁住某个方法时,往该表中插入一条相关的记录。这边需要注意,方法名是有唯一性约束的,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

执行完毕,需要delete该记录。

当然,笔者这边只是简单介绍一下。对于上述方案可以进行优化,如应用主从数据库,数据之间双向同步。一旦挂掉快速切换到备库上;做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍;使用while循环,直到insert成功再返回成功,虽然并不推荐这样做;还可以记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了,实现可重入锁。

基于数据库排他锁

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。其他没有获取到锁的就会阻塞在上述select语句上,可能的结果有2种,在超时之前获取到了锁,在超时之前仍未获取到锁。

获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,释放锁connection.commit()。

存在的问题主要是性能不高和sql超时的异常。

基于数据库的优缺点

上面两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。

  • 优点是直接借助数据库,简单容易理解。
  • 缺点是操作数据库需要一定的开销,性能问题需要考虑。

基于 redis

SETNX 加锁

使用redis的SET命令实现分布式锁,多个进程执行以下Redis命令:

1
SET key value PX time NX

这条命令是当key不存在时,将字符串值value关联到key,并设置过期时间为time毫秒.

  • 返回1,说明该进程获得锁.
  • 返回0,说明其他进程已经获得了锁,进程不能进入临界区。进程可以在一个循环中不断地尝试 SET 操作,以获得锁。

解锁

解锁很简单,直接把key删除就可以了.但是考虑一种情况.

进程A设置了一个较短的过期时间,在还未执行完之前锁已经过期被释放了.此时进程B拿到了锁进来,而A刚好又将锁删除.那这样由于过期时间设置过短,直接造成了所有redis锁失效.

为了避免这种情况,即错误删除了并非自己加的锁.我们需要在SET锁时,将UUID设置为value.在删除之前先确认这把锁是由自己加的,那么就需要有一个get的操作.

那因为get和del并不是原子操作,我们就需要采用lua脚本来保证原子性.

死锁问题

死锁问题即由设置过期时间来解决,但过期时间设置会存在一个问题:

当锁过期后,该进程还没执行完,可能造成同时多个进程取得锁。

另一种redis 加解锁方式-getset

我们同样假设进程P1已经首先获得了锁 lock.foo,然后进程P1挂掉了。接下来的情况:

  • 进程P4执行 SETNX lock.foo 以尝试获取锁
  • 由于进程P1已获得了锁,所以P4执行 SETNX lock.foo 返回0,即获取锁失败
  • P4执行 GET lock.foo 来检测锁是否已超时,如果没超时,则等待一段时间,再次检测
  • 如果P4检测到锁已超时,即当前的时间大于键 lock.foo 的值,P4会执行以下操作
1
GETSET lock.foo <current Unix timestamp + lock timeout + 1>
  • 由于 GETSET 操作在设置键的值的同时,还会返回键的旧值,通过比较键 lock.foo 的旧值是否小于当前时间,可以判断进程是否已获得锁
  • 假如另一个进程P5也检测到锁已超时,并在P4之前执行了 GETSET 操作,那么P4的 GETSET 操作返回的是一个大于当前时间的时间戳,这样P4就不会获得锁而继续等待。注意到,即使P4接下来将键 lock.foo 的值设置了比P5设置的更大的值也没影响。

如何解决单一进程执行时间超长,锁被抢走

WATCH, MULTI, EXEC, DISCARD事务机制实现分布式锁

Redis支持基本的事务操作

WATCH key
MULTI
some redis command
EXEC
以上被MULTI和EXEC包裹的redis命令,保证所有事务内的命令将会串行顺序执行,保证不会在事务的执行过程中被其他客户端打断。而WATCH命令能够监视某个键,当事务执行时,如果被监视的键被其他客户端修改了值,事务运行失败,返回相应错误(被事务运行客户端在事务内修改了值,不会造成事务运行失败)。

运用Redis事务所支持的以上特性,可以实现一个分布式锁功能.

通过WATCH命令监视redis锁键,当该键未被其他客户端修改值时,事务成功执行。当事务运行过程中,发现该值被其他客户端更新了值,任务失败,进行重试。

参考

http://redisdoc.com/string/set.html
https://www.cnblogs.com/crossoverJie/p/9339354.html
https://juejin.im/entry/5a502ac2518825732b19a595
https://blog.csdn.net/jj546630576/article/details/74910343
https://blog.csdn.net/youbl/article/details/80273019

多谢支持