zhaojie.tech/me
Redis 与并发 2026-10-09T00:00:00.000Z

自己写一遍分布式锁,才知道 Redisson 帮你做了什么

起因很实际:项目里已经有一层 RedisUtil 封装,我不愿意为一把锁再拖进 Redisson 整个依赖树,于是决定自己写。写完只用了一个下午,但把它用对、想清楚每个边界在哪,花了远不止一个下午。这篇把这个过程拆开——一把能用的 Redis 分布式锁,其实就五个要素。

要素一:加锁必须是一条命令

boolean acquired = RedisUtil.setNx(lockKey, lockValue, leaseTimeSeconds);

底层是 SET key value NX EX seconds。NX 保证”不存在才设置”,EX 保证持有者崩溃后锁会自动过期——加锁和设过期在一条命令里完成。早年常见的先 SETNX 再 EXPIRE 两步写法,进程在两步之间死掉就会留下一把永不过期的死锁,SET NX EX 就是为了埋掉这个窗口而生的。lockValue 是每次加锁生成的 UUID,它在下两个要素里是主角。

要素二:释放必须验证持有者

最朴素的释放是 DEL key,但它有个致命场景:你的锁过期了、别人加上了新锁,你回头一刀把别人的锁删了。所以释放要走 Lua:

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
    "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) " +
    "else return 0 end", Long.class);

GET 比对锁值、匹配才 DEL,两步必须在脚本里原子完成——拆成”先 GET 判断、再 DEL”,判断和删除之间锁照样可能易主,白比对。只有锁值等于自己的 UUID 才有资格删,这就是 UUID 的意义。

要素三:看门狗续期

leaseTime 定长了就有一个尴尬:业务没跑完、锁先过期。看门狗用一个共享的 ScheduledThreadPoolExecutor(2) 守护线程,每 leaseTime/3 秒醒一次:

watchdogExecutor.scheduleAtFixedRate(() -> {
    String currentValue = RedisUtil.get(lockKey);
    if (lockValue.equals(currentValue)) {
        RedisUtil.expire(lockKey, leaseTimeSeconds);   // 还是我持有 → 续期
    } else {
        locked = false;                                // 锁已易主 → 停止续期
    }
}, intervalSeconds, intervalSeconds, TimeUnit.SECONDS);

续期前先校验锁值仍匹配,锁丢了就自觉停狗;unlock() 则先 cancelWatchdog() 再释放,防止”刚删完锁又被续期”的鬼故事。

要素四:等待要自旋

抢不到锁时 100ms 一次自旋重试,超过 waitTime 返回失败。简单,够用——等待方拿不到锁的正确反应是告诉用户”操作繁忙”,而不是无限堵死一个 Tomcat 线程。

要素五:注解化,让锁的粒度看得见

裸用容易漏 unlock,我把锁做成了 @DistributedLock(key = "'sales:receipt:lock:' + #billId", waitTime = 15000, leaseTime = 120000) 注解 + AOP:SpEL 解析参数生成 lockKey,tryLock 失败抛”操作繁忙”,finally 里释放。目前注解式 11 处(工资核算/批量发放/归档冲销等按员工或月份加锁),编程式 10 处分布在结账事务、销售收款(sales:receipt:lock:{billId})、状态机流转、B2B 信用与订单提交等场景——编程式留给需要在中途判断、分层加锁的复杂路径。

参数怎么定,靠的是把每处锁的持有时长量出来而不是拍脑袋:工资单人发放、批量发放锁 leaseTime 给到 60 秒,归档支付类操作给到 120 秒,waitTime 3 到 15 秒不等——等待远小于持有,意味着”后来者快速失败提示用户”优先于”排队等锁”;结账、状态机这类秒级路径则把 leaseTime 压到 10 到 30 秒,缩小看门狗失灵时锁被他人误持的窗口。每处注解的 key、waitTime、leaseTime 都在同一行里可见,code review 时锁的粒度和时长一眼可审。

写完才知道的边界

自己写一遍的最大收获是知道它不像什么。看门狗的续期是”GET 比对 + EXPIRE”两步,比对通过到 EXPIRE 执行之间锁恰好过期、被新持有者抢走,旧看门狗的 EXPIRE 会给别人的锁续命——窗口极小,但存在;Redisson 看门狗默认 lockWatchdogTimeout=30s、每 10s(timeout/3)续一次,它把”校验仍持有 + 续期”收进了一段 Lua 脚本,脚本内原子、没有这个间隙;我这两步实现同样可以换 Lua 收掉窗口,只是正确性另有数据库层兜底,这个极小窗口可以接受。更要紧的认知是:锁易主后旧持有者可能还在写。业务执行超过 leaseTime 且看门狗没来得及续上,锁被别人拿走,旧持有者的下一步写操作就是并发写。所以我的原则是:先问这把锁是效率锁(防重复劳动,偶发重叠无害)还是正确性锁(重叠即资损)——正确性锁的场景,锁只能是第一道门,真正的不变式必须落在数据库层:唯一约束(重复插入直接被拦)或 CAS 条件更新(WHERE verify_status = 'VERIFIED' 式的乐观锁兜底)。至于 RedLock 多实例方案,争议的核心在时钟跳变下多数派是否可信;对我这种单 Redis 主从、且正确性另有 DB 兜底的系统,不引入是理性的取舍。

一句原则

分布式锁的五个要素——单命令加锁、验证持有者释放、看门狗续期、有限等待、可审计的粒度——都可以自己写;但锁只该当作协调效率的工具,正确性永远要有一个数据库层的兜底。

← 全部文章 赵杰 · me.zhaojie.tech