在一个JVM,如果一组业务操作要确保原子性,我们可以通过JDK提供的各种锁,如synchronized和ReentrantLock等。
而在一个分布式如果一个业务操作必须要确保原子性,单靠JDK的锁是无法锁住的。此时,我们就需要借助一个共享存储系统来实现一个分布式锁。
1、可否直接使用数据库锁实现分布式锁?
在并发度不高的场景中,我们可以使用数据库的行锁或者间隙锁来作为分布式锁,只有获取到了数据库锁的节点才可以继续往下执行。这数据库行锁是悲观锁,在其他线程获取不到锁的情况下,会进入阻塞状态,如果这种并发竞争度高的话,那么就会对数据库性能有开销了。
总结下数据库悲观锁的优缺点:
优点:
- 部署成本低,除了数据库,无需依赖其他组件;
- 数据库保证了持久化,可靠性高。
缺点:
- 如果并发度高,数据库锁的性能开销会增加,并且导致占用大量数据库连接,可能导致数据库连接池耗尽。
为了避免对数据库连接池造成影响,我们可以通过其他方式实现分布式锁,Redis就可以用来实现分布式锁。
2、如何用Redis实现一把单机版的分布式锁
Redis中的SETNX命令可以在设置key的时候,同时返回key是否已经存在,这有点像上锁,判断锁是否已经存在的场景。
SETNX命令
如果不存在,则设置key为保持字符串,并返回1;
当已经持有一个值时,不执行任何操作,并返回0。
在Redis 2.6.12之前,可以通过这个命令实现分布式锁
不过从Redis2.6.12版本开始,可以使用以下更简单的锁定原语:
SET key value [EX seconds|PX milliseconds|EXAT timestamp|PXAT milliseconds-timestamp|KEEPTTL] [NX|XX] [GET]
- NX:仅在不存在的情况下设置key;
- XX:仅设置已存在的key;
- GET:返回存储在key中的旧值,或者当key不存在时,返回nil。
如果是实现单Redis实例的分布式锁,则可以通过使用SET命令来实现。
获取锁
1 | // 客户端client_01尝试获取分布式锁distributed_lock,锁的过期时间为10秒 |
释放锁
释放锁的时候,为了避免勿删其他客户端的锁,我们需要先判断当前锁的持有者,如果当前锁的持有者为当前客户端,才可以发起释放锁,我们为了保证执行的原子性,这里用lua脚本来实现,release_lock.lua:
1 | if redis.call("get",KEYS[1]) == ARGV[1] then |
执行如下命令进行释放锁:
1 | ➜ script redis-cli -p 6379 -c --eval release_lock.lua distributed_lock , client_01 |
如果我们这个时候,在另一个客户端client_02执行释放锁,那么将返回0,表示没有释放掉锁,因为该锁不属于client_02:
1 | # 获取锁 |
续命锁
细心的同学应该会看到,我在上面的例子中,给锁设置了10秒的过期时间。
那么问题来了:要是10秒内,业务没有执行完毕,而此时,锁有过期了,不是会被其他线程获取到锁就立刻开始执行业务了吗?
为了避免这种情况,我们需要给即将过期的锁进行续命。
如果我们的锁超时时间为10秒,那么我们可以在获取到锁之后,开启一个异步线程,设置一个间隔时间(10秒内)定时重新给锁过期时间为10秒后,直到业务执行完毕,然后再异步线程终止操作,再释放锁。这样就可以保证业务执行过程中,锁都不会过期了。
以上是实现单Redis实例的分布式锁。不过单Redis实例的分布式锁具有单点故障的风险,为了增加分布式锁的可靠性,我们需要实现多Redis节点的分布式锁。
3、如何用Redis实现一把集群版的分布式锁
Redis的作者设计了用于实现多节点的Redis分布式锁算法:Redlock,并推荐使用该算法实现分布式。
该算法的关键思路是:让客户端依次向多个Redis节点请求加锁,如果能够获得半数以上的实例的锁,那么就表示获取锁成功。否则表示加锁失败。
加锁失败的情况下,需要向所有Redis节点发起释放锁的请求。
如果获取锁的过程消耗的时间超过了锁的有效时间,那么也算加锁失败。
可用的Redis分布式实现:
- Redlock-rb Ruby 实现
- Redlock-py Python 实现
- Pottery Python 实现
- Aioredlock Asyncio Python 实现
- Redlock-php PHP 实现
- PHPRedisMutex 进一步的PHP实现
- cheprasov/php-redis-lock 用于锁的 PHP 库
- rtckit/react-redlock 异步 PHP 实现
- Redsync Go 实现
- Redisson Java 实现
- Redis::DistLock Perl 实现
- Redlock-cpp C++ 实现
- Redlock-cs C#/.NET 实现
- RedLock.net C#/.NET 实现)。包括异步和锁定扩展支持
- ScarletLock 具有可配置数据存储的 C# .NET 实现
- Redlock4Net C# .NET 实现
- node-redlock NodeJS实现
更多关于Redis分布式锁的相关内容,参考:Distributed locks with Redis[1]
4、通过lua脚本执行多个Redis命令,原子性一定可以得到保证吗?
Redis作为内存数据库,其“事务”与大多数人认为的经典 DBMS 中的事务完全不同。
4.1、Redis事务与MySQL事务有何不同?
Redis并没有类似MySQL的redolog基于WAL机制去实现原子性和持久性,通过undolog的MVCC支持隔离性,从而避免幻读。
虽然Redis有MULTI和EXEC命令配合使用来实现多个操作的事务块,但并不能实现MySQL那样的ACID事务特性。为了保证原子性,MULTI/EXEC 块将 Redis 命令的执行延迟到 EXEC。所以客户端只会把命令命令堆在内存的一个指令队列里,直到执行EXEC的时候,一起执行所有的命令,从而实现原子性。
把指令记录到指令队列过程中,如果检测出语法有错误的命令,这种情况下执行EXEC命令会丢弃事务,原子性可以得到保证。
如果Redis事务块执行过程中部分命令报错报错之后,数据是不会回滚的。此时原子性得不到保证
例如,如果指令语法没问题,只是操作的类型不匹配,是检测不出来的,实际执行EXEC的时候,会正确执行没问题的指令,有问题的指令报错,导致事务块的原子性不能得到保证:
1 | 127.0.0.1:6379> MULTI |
另外,执行事务块的过程中,是不会触发执行RDB的,所以事务命令操作不会保存到RDB中。但是会记录到AOF文件中。
如果事务执行过程中异常停机,导致AOF文件出错,此时可以**使用redis-check-aof对原来的 AOF 文件进行修复,清除事务中已完成的操作,进而再启动redis。**这种情况,原子性是可以得到保证的。
既然谈到了原子性,我们再来看看Redis事务如何才能实现隔离性。
4.2、如何保证Redis的隔离性?
**隔离性:**不同事务先后提交,最终的执行效果是串行的,也就是在执行过程中,事务能感知到数据的变化只有是自己操作引起的,不会因为其他事务操作导致感知到数据变化。
在MySQL的InnoDB引擎的可重复读隔离级别中,为了避免幻读,引入了间隙锁,为了避免不可重复读,引入了MVCC。
当需要修改数据的时候,会采用当前读模式,锁定需要修改的记录,从而避免多个事务同时更新同一条记录导致的并发过程中数据被覆盖,不能得到预期的执行结果。
而Redis中修改数据是不会锁定需要修改的记录的,并没有MySQL的当前读机制。
当前读和快照读
当前读:读取记录的最新版本,并且读取时要保证其他并发事务不能修改当前记录,为此会对读取的记录进行加锁。
可以使用**SELECT … LOCK IN SHARE MODE、SELECT … FOR UPDATE、UPDATE、DELETE、INSERT、**操作实现当前读。本质上都是在这些操作的过程中,先请求获取锁。
快照度:读取的是快照版本,也就是历史版本,通过MVCC + undo log实现。
为了保证隔离性,就需要通过其他方式来实现了。Redis是通过WATCH命令来实现MySQL的当前读机制的。
与MySQL的事前锁定记录不同,Redis采用的是事后通知记录变更进而取消需要当前读的操作。
WATCH命令:
Redis Watch 命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
以下是通过WATCH命令保证隔离性的例子: