VOL.001 · 创刊号Token 天团 API

Token 天团

—— 一份关于代码与生活的个人刊物 ——

技术Java教程

分布式锁的三重门:数据库稳、Redis 快、Redisson 全——Java 工兵连第 7 篇

「Java 工兵连」第 7 篇,讲分布式锁。单机时代 synchronized 一把梭,服务一拆集群,锁就没了——三个实例各锁各的,库存照超不误。分布式锁的三种实现,难度、性能、坑数依次递增,我称之为三重门。这篇带你把三道门都走一遍,重点拆中间那道——Redis 裸锁的三大坑,个个都是生产事故的常客。

一、门一:数据库锁——物业登记本

最朴素的方案,零新组件。唯一索引占座:

CREATE TABLE distributed_lock (
  lock_key  VARCHAR(64) NOT NULL,
  owner     VARCHAR(64) NOT NULL,
  created_at DATETIME,
  UNIQUE KEY uk_lock (lock_key)      -- 唯一索引就是互斥性本身
);
-- 加锁 = insert 成功;释放 = delete;别人 insert 报 Duplicate → 没抢到

另一路是 SELECT ... FOR UPDATE(悲观行锁),锁随事务提交释放。

它的好处是稳:唯一索引的互斥由数据库绝对保证,不存在「锁丢了」的脑裂问题,强一致场景(对账、资金)反而偏爱它。代价是慢:磁盘数据库扛不住高并发抢锁,还有三个附赠的坑——不可重入、无超时自动释放(持锁者崩了记录还在,要靠定时任务清理僵尸)、锁服务和业务库同生共死。

定位:并发量低、已经有 DB、不想引新组件的场景,第一选择。 别看不起它——多数业务的「防止重复提交」,一张唯一索引表就解决了。

二、门二:Redis 裸锁——租期储物柜

三重门对比与 Redis 裸锁三大坑

Redis 方案的性能比 DB 高一个量级,但裸写有三坑,一坑比一坑深:

坑①:加锁两步走 → 死锁。 老代码 SETNX 成功、还没来得及 EXPIRE 就崩了——锁永不过期,后面所有人死等。修法是一条原子命令搞定:

SET lock:order:1001 "uuid-a" NX EX 30   # 不存在才设置 + 过期,一步到位

坑②:删锁不验身 → 删了别人的。 时间线:A 拿锁(30 秒租期)→ 业务慢,锁到期自动释放 → B 拿到锁 → A 办完事执行 DEL——删掉的是 B 的锁 → C 又进来,B 和 C 并发。修法分两层:value 写唯一标识(UUID),删锁前先校验「是我加的锁」;但「GET 比较 + DEL」又是两步非原子,终极修法是 Lua 脚本让 Redis 原子执行:

if redis.call("GET", KEYS[1]) == ARGV[1] then   -- 是我的锁才删
    return redis.call("DEL", KEYS[1])
else
    return 0
end

坑③:到期没人续 → 业务没跑完锁被抢。 租期定 30 秒,业务偏偏跑了 35 秒——第 31 秒起互斥已破。定长没法定(太短误伤、太长宕机恢复慢),修法是自动续期,而这正是第三道门的看家本领。

三坑串起来就是本文第一口诀:加锁要原子,释放要验身,超时要续命。

三、门三:Redisson——带管家的储物柜

Redisson 把裸锁的坑全部封装掉了,标准姿势:

RLock lock = redisson.getLock("lock:order:" + orderId);   // 注意:锁到资源,不是锁方法
if (lock.tryLock(5, TimeUnit.SECONDS)) {                    // 最多等 5 秒;不指定 leaseTime
    try {
        deductStock(orderId);
    } finally {
        lock.unlock();                                      // 必须放 finally
    }
} else {
    throw new BizException("抢锁失败,稍后重试");
}

它替你做了四件事:

  1. 加解锁全 Lua 原子——坑①② 从根上消失;
  2. 可重入:锁结构是 hash(field=客户端线程标识,value=重入计数),同线程再进不卡死;
  3. 看门狗(watchdog)续命:不指定租期时默认锁 30 秒,后台每 10 秒(租期的 1/3)自动续回 30 秒——业务跑多久锁活多久;客户端宕机,看门狗跟着死,锁最多 30 秒自动释放,这就是「不死锁」的保证;
  4. tryLock(waitTime, leaseTime, unit):等锁上限 + 租期自己定。

一个必须知道的反直觉细节:一旦指定了 leaseTime,看门狗立即失效(tryLock(5, 10, TimeUnit.SECONDS) = 最多等 5 秒、锁 10 秒后必放、不续期)。很多人以为 watchdog 是无条件的,结果 10 秒后锁没了互斥破防——要么不指定租期交给看门狗,要么自己确保业务在租期内完成。

四、终极提醒:锁不是万能药

两件事比选哪道门更重要:

锁可能失效,概率不为零。 Redis 主从架构下,master 写入锁后还没同步给 replica 就宕机,新 master 上没有这把锁——两个客户端同时持锁。(RedLock 算法试图解决,但 Martin Kleppmann 与 Antirez 的著名论战之后,业界共识是:它依赖时钟假设,代价高且不彻底,感兴趣的读者搜「How to do distributed locking」。)正确姿势是锁 + 幂等双保险:锁降低冲突概率,幂等(唯一约束、状态机、版本号乐观锁、fencing token)保证即使锁失效也不出错。资金场景再加对账兜底。

锁粒度锁到资源,不锁方法。 lock("deductStock") 和 lock("order:" + orderId) 是两个世界:前者把所有订单串成一条队,后者只锁同一单。锁越细,并发越高——这与 HashMap 扩容、线程池隔离是同一个思想:缩小临界区,缩小爆炸半径。

顺带一句 ZooKeeper:临时顺序节点方案强一致、会话断开自动释放,比 Redis 可靠;代价是性能低一截、运维重一份。没有中间件团队背书,一般到不了需要它的规模。

老兵点评

  1. 先问「一定要锁吗」再问「用哪把锁」。防重复提交 → 数据库唯一索引就够;计数扣减 → 数据库乐观锁/UPDATE ... WHERE stock>0 更简单;真有跨服务长流程互斥,才轮到 Redisson。分布式锁是最后手段,不是默认组件。
  2. Redisson 用得对不对,就看两点:finally 里 unlock(防死锁)、leaseTime 与看门狗的关系(防裸奔)。Code Review 盯这两处,能拦下九成事故。
  3. 面试讲分布式锁的段位差:初级背 SET NX EX;中级讲验身删锁和 Lua 原子;高级讲主从切换丢锁、RedLock 论战和「幂等兜底」——到这一层,你讲的是分布式系统的不可靠本质,不是 API。

带走三句话

  1. 三重门定位:数据库稳(唯一索引占座)、Redis 快(SET NX EX)、Redisson 全(Lua+可重入+看门狗)。
  2. Redis 裸锁三坑口诀:加锁要原子,释放要验身,超时要续命;Redisson 里指定 leaseTime = 看门狗失效。
  3. 锁粒度锁到资源不锁方法;锁只是第一道防线,幂等才是兜底。

本篇是「Java 工兵连」第 7 篇。→ 上一篇:第 6 篇 · MySQL 索引最左前缀 | 继续阅读:第 8 篇 · 线程安全的集合怎么选 | 浏览整个专栏 →。

❦ 终 ❦

← 回到首页全部归档 →