SETNX 无法实现读写锁,因其仅能判断 key 存在性,无法区分持有者、锁类型及读者数量,更无法支持读写锁的语义;必须用 Redis HASH 存储 mode、持有者 ID 等字段以保障原子性。

为什么不能直接用 SETNX 实现读写锁?
因为 SETNX 只能判断 key 是否存在,无法区分“当前持有者是谁”“是读锁还是写锁”“有多少个读者在共享”,更没法实现“写锁排斥所有读写、读锁只排斥写锁”这种语义。读写锁本质是状态机,必须用 Redis 的 HASH 结构存模式(mode)、持有者 ID、重入计数等字段,否则原子性无法保障。
读写锁 Lua 脚本的关键结构怎么写?
核心是用 HGET 读取 mode 字段做分支判断,再配合 HSET/HINCRBY/PERSIST 等操作更新状态。以下是最简可用的读锁加锁脚本(已压缩为单行便于 Redis 执行):
local mode = redis.call('hget', KEYS[1], 'mode'); if (mode == false) then redis.call('hset', KEYS[1], 'mode', 'read'); redis.call('hset', KEYS[1], ARGV[1], 1); redis.call('pexpire', KEYS[1], ARGV[2]); return 1; end; if (mode == 'read') then local count = redis.call('hincrby', KEYS[1], ARGV[1], 1); redis.call('pexpire', KEYS[1], ARGV[2]); return count; end; if (mode == 'write' and redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('hincrby', KEYS[1], ARGV[1], 1); redis.call('pexpire', KEYS[1], ARGV[2]); return redis.call('hget', KEYS[1], ARGV[1]); end; return redis.call('pttl', KEYS[1]);
说明:
-
KEYS[1]是锁名(如rwlock:order:1001),必须全局唯一 -
ARGV[1]是客户端唯一标识(建议用UUID + threadId拼接,防重入混淆) -
ARGV[2]是锁自动过期时间(毫秒),必须大于业务最长执行时间 - 脚本返回值:成功时返回当前重入次数;失败时返回剩余 TTL(负数表示已过期)
读锁和写锁的释放逻辑差异在哪?
读锁释放必须检查“是否还有其他读者”,只有自己是最后一个才删 key;写锁释放则只要确认是持有者就能直接删。所以解锁脚本不能复用,得分开写:
读锁释放脚本(需传入客户端 ID 和锁名):
if redis.call('hget', KEYS[1], 'mode') ~= 'read' then return 0 end; local count = redis.call('hincrby', KEYS[1], ARGV[1], -1); if count > 0 then redis.call('pexpire', KEYS[1], ARGV[2]); return 1; else redis.call('hdel', KEYS[1], ARGV[1]); if redis.call('hlen', KEYS[1]) == 1 then redis.call('del', KEYS[1]); end; return 1; end;
写锁释放脚本(更严格,只允许写锁持有者执行):
if redis.call('hget', KEYS[1], 'mode') ~= 'write' then return 0 end; if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then return 0 end; redis.call('del', KEYS[1]); return 1;
注意:hlen 返回 HASH 中字段数,这里要减去 mode 字段,所以判断是否等于 1 表示只剩 mode 字段、无任何 reader。
SpringBoot 里调用这些 Lua 脚本要注意什么?
别手写 RedisTemplate.execute() 多参数调用,容易错位。推荐封装成工具类,用 DefaultRedisScript 预编译:
- 脚本必须用
String类型声明,不能用Resource加载后 toString(),否则换行符可能破坏原子性 -
RedisTemplate的泛型要设为String,避免序列化干扰 Lua 返回值 - 超时时间参数(
ARGV[2])务必用毫秒单位传入,和pexpire匹配,别误用expire(秒级) - 客户端 ID 必须全局唯一且线程安全生成,用
UUID.randomUUID().toString() + ":" + Thread.currentThread().getId()
最易被忽略的一点:读写锁的 key 命名必须带业务上下文(比如 rwlock:stock:sku_123),否则不同资源共用一个锁会引发意外阻塞——这不是脚本问题,而是设计时没想清楚锁粒度。


















