Redis 分布式锁踩过的 5 个坑:从 SETNX 到 Redlock 到 fencing token

秒杀超卖 1 件复盘:Redis 主从切换导致锁失效。本文讲透 5 种错误实现 + 正确的 SET NX PX + Lua、Redisson watchdog、Redlock 算法、Martin Kleppmann 的质疑、fencing token 思路,以及 etcd 强一致锁的对比。附完整 Java 代码 + 选型建议。

促销活动期间,有用户成功"秒杀"到 2 件库存只剩 1 件的商品,客服爆炸。复盘发现是分布式锁失效:Redis 主从切换的瞬间,新主上没有这把锁,两个进程同时进了临界区。本文把 Redis 分布式锁的 5 个常见错误实现讲清楚,然后给出 Redisson + Redlock 的正确用法,附完整代码。

事故场景

21:00:00.100 服务 A 用 SETNX 在 Redis master 上拿到 lock:item_999
21:00:00.150 Redis master 网络抖动,sentinel 选 slave 升级
21:00:00.200 Redis 新 master 上没有 lock:item_999(没复制过去)
21:00:00.250 服务 B SETNX lock:item_999 → 成功,也认为自己拿到锁
21:00:00.300 服务 A 和服务 B 同时 decr stock,导致 stock 减成 -1

核心问题:Redis 主从是异步复制,master 上 SETNX 成功但 slave 还没收到。一旦发生 failover,锁就丢了。

错误实现 1:setnx + expire 两步

// 错误:不原子,中间挂了导致永久锁
public boolean acquireLock(String key, String value, int ttlSec) {
    Boolean ok = redis.opsForValue().setIfAbsent(key, value);   // SETNX
    if (Boolean.TRUE.equals(ok)) {
        redis.expire(key, ttlSec, TimeUnit.SECONDS);            // 这步可能没执行
        return true;
    }
    return false;
}

如果 SETNX 之后进程崩了(GC pause / kill -9),expire 没执行,这把锁永远不会释放。

错误实现 2:setnx + 用 value 存过期时间

// 错误:存过期时间在 value 里,过期判断需要 GET,有竞态
public boolean acquireLock(String key, int ttlSec) {
    long expireAt = System.currentTimeMillis() + ttlSec * 1000;
    Boolean ok = redis.opsForValue().setIfAbsent(key, String.valueOf(expireAt));
    if (Boolean.TRUE.equals(ok)) return true;

    // 没拿到,看是不是过期了
    String oldExpireStr = redis.opsForValue().get(key);
    if (oldExpireStr != null && Long.parseLong(oldExpireStr) < System.currentTimeMillis()) {
        String newExpireStr = String.valueOf(expireAt);
        String prev = redis.opsForValue().getAndSet(key, newExpireStr);
        // 竞态:两个线程同时走到这里,都会成功
        // 实际只有第一个拿到锁,但代码无法区分
        return prev != null && Long.parseLong(prev) < System.currentTimeMillis();
    }
    return false;
}

这种写法在《Redis 实战》里出现过,看着聪明实际有 bug。

正确实现 1:SET key value NX PX

// 单条命令,原子 setnx + expire
public boolean acquireLock(String key, String value, int ttlMs) {
    Boolean ok = redis.execute((RedisCallback<Boolean>) c -> {
        return c.set(key.getBytes(), value.getBytes(),
            Expiration.milliseconds(ttlMs),
            RedisStringCommands.SetOption.SET_IF_ABSENT);
    });
    return Boolean.TRUE.equals(ok);
}

// 释放锁必须用 Lua 保证"判断 + 删除"原子
public boolean releaseLock(String key, String value) {
    String script =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "    return redis.call('del', KEYS[1]) " +
        "else " +
        "    return 0 " +
        "end";
    Long result = redis.execute(new DefaultRedisScript<>(script, Long.class),
        Collections.singletonList(key), value);
    return result != null && result == 1;
}

这是最基础正确的实现:

  1. SET NX PX 一条命令搞定 setnx + 过期
  2. 释放锁要传入持有时的 value,Lua 脚本保证 "GET + 比较 + DEL" 原子
  3. value 通常用 UUID 或 threadId,防止误删别人的锁

错误实现 3:不带 owner 的 release

// 错误:直接 DEL,可能把别人的锁删了
public void releaseLock(String key) {
    redis.delete(key);     // ← 万恶之源
}

// 场景:
// T1 拿到 lock,设置 30s 过期
// T1 业务执行 35s(超时,锁自动释放)
// T2 拿到同一个 lock(因为 T1 已过期)
// T1 业务执行完,调 releaseLock(key) → 把 T2 的锁删了
// T3 也能拿到锁 → 临界区两个人同时进

错误实现 4:用 Java Timer / ScheduledExecutor 续期

// 错误:看起来对,实际很多坑
public class BadAutoRenew {
    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();

    public void acquireWithRenew(String key, String value, int ttlSec) {
        if (acquireLock(key, value, ttlSec * 1000)) {
            // 每 1/3 ttl 续期一次
            scheduler.scheduleAtFixedRate(() -> {
                renew(key, value, ttlSec);     // ← 如果业务已经 release 了,这里还在续
            }, ttlSec / 3, ttlSec / 3, TimeUnit.SECONDS);
        }
    }
}

需要在 release 时取消定时任务,需要处理续期失败,需要处理 release 之后续期仍然在跑的边界。每个细节都容易写错。

推荐实现:Redisson(生产首选)

Redisson 把上面所有细节都封装好,直接用:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.27.0</version>
</dependency>
@Service
public class SecKillService {
    @Autowired private RedissonClient redisson;
    @Autowired private InventoryRepo invRepo;

    public OrderResult secKill(Long itemId, Long uid) {
        RLock lock = redisson.getLock("lock:item:" + itemId);
        try {
            // tryLock(waitTime, leaseTime, unit)
            // waitTime=0: 没拿到立即返回(适合秒杀,排队等没意义)
            // leaseTime=-1: 用 watchdog 自动续期,30 秒一次
            boolean locked = lock.tryLock(0, -1, TimeUnit.MILLISECONDS);
            if (!locked) {
                return OrderResult.fail("too_busy");
            }
            try {
                Inventory inv = invRepo.findById(itemId);
                if (inv.getStock() <= 0) return OrderResult.fail("sold_out");
                invRepo.deduct(itemId, 1);
                return OrderResult.ok();
            } finally {
                if (lock.isHeldByCurrentThread()) lock.unlock();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return OrderResult.fail("interrupted");
        }
    }
}

Redisson 内部细节:

  • SET NX PX 加锁,带 UUID + threadId 作为 owner
  • WatchDog 后台线程每 10 秒续期到 30 秒(默认),客户端进程崩了自动停止续期
  • release 用 Lua,判断 owner 才 unlock
  • 支持可重入(hash 结构存重入次数)
  • 提供 Pub/Sub 实现"等锁"语义

但 Redisson 单 master 仍有 failover 风险

这就是开头事故的根因。解决方案:Redlock(Redis 作者 antirez 提出的算法)。

Redisson 内置 Redlock 实现:

RLock lock1 = redisson1.getLock("lock:item:" + itemId);
RLock lock2 = redisson2.getLock("lock:item:" + itemId);
RLock lock3 = redisson3.getLock("lock:item:" + itemId);
RLock lock4 = redisson4.getLock("lock:item:" + itemId);
RLock lock5 = redisson5.getLock("lock:item:" + itemId);

RedissonMultiLock redLock = new RedissonMultiLock(lock1, lock2, lock3, lock4, lock5);
try {
    boolean ok = redLock.tryLock(3, 30, TimeUnit.SECONDS);
    if (ok) {
        try {
            // 临界区
        } finally {
            redLock.unlock();
        }
    }
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

Redlock 的争议

Martin Kleppmann 写过著名的 How to do distributed locking 质疑 Redlock。核心论点:

  1. GC pause 风险:client 拿到锁后,JVM 长 GC 30 秒,锁过期了,client 还以为持锁,这时另一个 client 拿到了锁,两边同时操作
  2. 时钟漂移:Redlock 假设所有节点时钟差不多,实际容器化环境时钟不可靠

antirez 反驳:GC pause 这个问题任何分布式锁都有,不是 Redlock 独有。要彻底解决得用 fencing token。

// fencing token 思路:每次加锁拿到一个单调递增的 token
// 临界区操作时把 token 带给共享资源,资源校验 token 是不是最新
public Result secKill(Long itemId) {
    long token = redisson.getAtomicLong("lock:item:" + itemId + ":token").incrementAndGet();
    // ... 拿锁 ...
    invRepo.deductWithFencing(itemId, 1, token);    // SQL: WHERE last_token < ?
}

多数业务场景对一致性要求没到那种程度,Redisson + watchdog + 主从切换避免就够用。要追求 100% 正确性,得用 etcd / Zookeeper 这种 CP 系统的锁。

etcd 分布式锁(强一致)

import io.etcd.jetcd.*;

public Result secKillEtcd(Long itemId) throws Exception {
    Client client = Client.builder().endpoints("http://etcd:2379").build();
    Lock lockClient = client.getLockClient();

    // 拿 lease,30s 过期
    Lease leaseClient = client.getLeaseClient();
    long leaseId = leaseClient.grant(30).get().getID();

    // 加锁
    String key = "/lock/item/" + itemId;
    LockResponse lockResp = lockClient.lock(ByteSequence.from(key, UTF_8), leaseId).get();
    String actualKey = lockResp.getKey().toString(UTF_8);

    // KeepAlive 自动续期
    StreamObserver<LeaseKeepAliveResponse> observer = leaseClient.keepAlive(leaseId, ...);

    try {
        // 临界区
        return processOrder(itemId);
    } finally {
        observer.onCompleted();
        lockClient.unlock(ByteSequence.from(actualKey, UTF_8)).get();
        leaseClient.revoke(leaseId);
    }
}

etcd 用 Raft,强一致,不会丢锁。代价:吞吐量比 Redis 低一个数量级(Redis ~10 万 QPS vs etcd ~5000 QPS 锁请求)。

选型建议

业务场景                          推荐方案
=======================================================
内部限流 / 防重复点击              Redisson 单节点(够用)
普通业务幂等(订单创建)            Redisson 哨兵/集群
秒杀 / 库存扣减(高一致要求)       Redisson + 业务层乐观锁 fencing
支付 / 金融账务(强一致 + 不能丢)  etcd / Zookeeper 锁 + DB 事务

团队规范

  1. 禁止直接用 redis SETNX/EXPIRE 两步写法
  2. 所有分布式锁统一走封装好的 LockTemplate(内部用 Redisson)
  3. 锁的 key 命名规范:lock:{业务名}:{资源id}
  4. 锁超时时间业务方必须显式声明,不允许默认无限等待
  5. 所有临界区操作前必须 doublecheck(防止锁过期被别人重入)
  6. 强一致场景必须配合 fencing token 或乐观锁版本号

分布式锁是看起来简单实际深坑的东西。Redis 的 SETNX 只是基础设施,工程上还得叠加 watchdog、Redlock、fencing token、监控等等。生产用 Redisson 加业务层兜底是最划算的方案,既不至于像 etcd 一样吞吐量惨,也不至于像裸 SETNX 一样写一堆 bug。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

雪花算法时钟回拨导致主键冲突的事故复盘:4 种工程化修法对比

2026-5-19 11:17:56

技术教程

缓存穿透击穿雪崩三件套实战:从 5000 QPS 崩溃到 p99 180ms

2026-5-19 11:22:07

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索