知识预热
分布式锁和传统加锁方式的区别
以最传统的 Synchronized 关键字为例。
- 范围
- Synchronized:Java 内置的一种并发控制机制,主要用于在单个 JVM 内的多线程之间同步共享资源,如在多个线程间共享一个对象的方法或代码块。
- 分布式锁:支持在分布式系统中的不同节点之间同步共享资源,比如在分布式缓存、分布式数据库、分布式任务调度等场景下。
- 可靠性
- Synchronized:通常比较可靠,但需要注意避免死锁和竞争条件
- 分布式锁:需要考虑分布式系统的复杂性,包括网络延迟、节点故障、数据一致性等问题,确保在分布式环境下也能保持正确的同步
- 实现方式
- Synchronized:由 JVM 内部实现
- 分布式锁:通常需要借助外部组件或服务来实现,如基于分布式协调框架(如ZooKeeper)的锁机制,或是使用分布式数据库实现
- 性能
- Synchronized:性能相对较高,但在高并发情况下可能导致线程竞争和阻塞
- 分布式锁:涉及网络通信和分布式协调,通常更加耗时,并且需要考虑分布式系统的稳定性和可靠性
分布式锁可以起到的作用
- 并发控制:防止多个节点同时访问关键资源
- 流程串行:在分布式环境中维护操作的顺序,确保操作按照预期的顺序执行
所谓的资源征用、重复执行、竞态条件、数据一致都一样,核心就是并发控制。分布式锁的目的和锁的目的一样,只是影响的范围不同。
常见的分布式锁
- Redis:支持基本的分布式锁实现,通过在Redis中设置一个特定的键值来表示锁状态,利用Redis的原子操作来实现锁的获取和释放
- Redisson:基于Redis的Java框架,它提供了各种类型的分布式锁,如普通锁、公平锁、可重入锁等。Redisson还支持异步锁、红锁、信号量、闭锁等功能
- Zookeeper:提供了有序临时节点,可以用来竞争获取锁,并通过监视机制实现锁的超时和自动释放
- etcd:开源的分布式键值存储系统,类似于ZooKeeper。etcd可以用于实现分布式锁,通过分布式的事务操作来保证锁的正确性
- 数据库:在一些场景下,可以通过数据库的特性来实现分布式锁,比如使用数据库行级锁或乐观锁来控制并发访问
分布式锁的实现思路
关键点:只能有一个线程获取锁
利用 Redis 的 SETNX 方法(Set If Not Exist)
redis -> setnx [key] [value]
1
redis -> expire [key] [seconds]
使用 SETNX 创建键值对后,设置过期时间。
设置过期时间,目的是防止使用 SETNX 加锁到释放期间程序崩溃,导致锁无法被释放,从而导致无法被正常获取。
虽然这样能防止完全失去锁,但是加入过期时间会带来新的问题。

为了避免上述问题出现,应在删除操作时判断这把锁当前是否属于自己,当任务结束时,若锁(key)对应的 value 不为当前线程的信息,则不进行删除操作。
上述内容解决了分布式锁的误删除问题,但实际依然带来了其他问题。在上图的删除操作前,我们增加了判断逻辑,判断这把锁当前是否属于自己,而这个逻辑将使删除操作不再是一个原子性操作,如果不是原子性操作,就必然存在阻塞的可能。
我们来将图细化一下:

因此必须确保 判断锁并删除 这个行为是具备原子性的。
Redis 支持 Lua 脚本,在 Java 中可以使用 RedisTemplate 调用 Lua 脚本,保证 判断-删除 这个操作的原子性,即可解决以上问题。
此时请考虑第三个问题,setnx 和 expire 操作之间是否存在原子性问题,很显然是存在的:

但是所幸我们已经知道可以调用 Lua 去解决了:
Jedis jedis = new Jedis("localhost", 6379); String lockKey = "mylock"; String lockValue = "locked"; // 超时单位(秒) int lockTimeout = 10; String script = "local lockKey = KEYS[1]\n" + "local lockValue = ARGV[1]\n" + "local lockTimeout = tonumber(ARGV[2])\n" + "if redis.call(\"SETNX\", lockKey, lockValue) == 1 then\n" + " redis.call(\"EXPIRE\", lockKey, lockTimeout)\n" + " return \"OK\"\n" + "else\n" + " return nil\n" + "end"; List<String> keys = Collections.singletonList(lockKey); List<String> args = Arrays.asList(lockValue, String.valueOf(lockTimeout)); String result = (String) jedis.eval(script, keys, args); jedis.close(); if ("OK".equals(result)) { // 获取锁成功 } else { // 获取锁失败 }
Redis 还提供了另一个命令确保来实现类似于以上的 Lua 脚本逻辑,所以用 setnx 可能会存在问题,但是使用 Redis 不会出现获取锁时的原子性问题:
// NX: 当数据库中key不存在时,可以将key-value添加到数据库
// XX: 当数据库中key存在时,可以将key-value设置到数据库,与NX参数互斥
// EX: key的超时秒数。
// PX: key的超时毫秒数,与EX参数互斥
set key value [NX] [XX] [EX <seconds>] [PX <millseconds>]
Redisson 概述
上一章的简单模型还面临几个问题:
- 任务执行时间超过了设置的过期时间,锁还是会被释放
- 没有锁的可重入设计
- 没有获取锁失败重试机制
- 没有考虑 Redis 集群的情况
这里引入本文的主角 Redisson,直接参照源码对 Redisson 分布式锁部分进行解析。
首先还是要明确,Redis 通过 Lua 脚本实现原子性操作,因为 Redis 是单线程串行处理的。
此外通过 Lua 脚本复合操作可以减少网络开销。
类似 JVM 的热点代码,Redis 同样会缓存热点 Lua 脚本,避免频繁编译操作。
Lua 脚本通过 [SHA1 散列值]-[内容] 的形式存放在 Redis 的特定缓存区中。无论 Lua 脚本是否已经被缓存,使用后都会返回一个 SHA-1 散列值,后续可以直接通过 SHA-1 散列值来调用脚本。
我们已经知道获取锁并设置过期时间、判断并释放锁这类操作需要依赖 Lua 脚本,那么 Redisson 中是对这些基本操作封装好了 Lua 脚本吗?
这里可以查看 org.redisson.api.RScript 接口,它提供了以下几个方法:
// 通过 SHA-1 定位并执行缓存在 Redis 中的 Lua 脚本
<R> R evalSha(Mode mode, String shaDigest, ReturnType returnType, List<Object> keys, Object... values);
// 下面多传入的 key 与主键无关,而是用于定位集群中缓存 Lua 脚本的 Redis 节点
<R> R evalSha(String key, Mode mode, String shaDigest, ReturnType returnType, List<Object> keys, Object... values);
// 执行 Lua 脚本
<R> R eval(Mode mode, String luaScript, ReturnType returnType, List<Object> keys, Object... values);
// 多传入的 key 作用同上
<R> R eval(String key, Mode mode, String luaScript, ReturnType returnType, List<Object> keys, Object... values);
// 将 Lua 脚本存入 Redis 缓存并获取到 SHA-1 散列值
String scriptLoad(String luaScript);
// 通过 SHA-1 散列值检查缓存的脚本是否存在
List<Boolean> scriptExists(String... shaDigests);
// 刷新 Lua 脚本
void scriptFlush();
org.redisson.RedissonScript 类实现了 RScript 接口,并且引入了 Codec 和 CommandAsyncExecutor 两个类。
public class RedissonScript implements RScript {
private final Codec codec;
private final CommandAsyncExecutor commandExecutor;
...
}
其中,Codec 用于进行对象的序列化和反序列化。在 Redis 中,数据通常以字符串形式存储,但是在使用 Redisson 时,你可以存储和检索 Java 对象。为了在 Java 对象和 Redis 存储之间进行转换,需要使用编解码器 Codec。你可以根据自己的需要选择不同的编解码器,例如 JSON 编解码器、MsgPack 编解码器等。在 RedissonScript 中,Codec 的作用是用于在执行 Lua 脚本时,将 Java 对象转换为字节流,以便在 Lua 脚本中使用。
CommandAsyncExecutor 是 Redisson 执行 Redis 命令的执行器,负责与 Redis 服务器进行通信,发送命令并接收响应。从名字可以看出,它支持以异步方式与 Redis 服务器进行通信。