各位架构师、后端大佬们,大家好。

今天我们要聊一个让无数英雄折戟沉沙的“坑”:分布式锁

很多刚把单体应用拆成微服务的同学,是不是会遭遇一种“灵异事件”:

      “我在本地测试好好的,synchronized 锁得死死的,怎么一上集群,数据就乱了?库存怎么变成负数了?难道我的代码有 Bug?”

核心痛点:JVM 是一座孤岛

在单体应用(Monolith)里,所有线程都跑在同一个 JVM 进程里。
synchronized 或 ReentrantLock 就像部门经理手里的印章。只要是在这个部门(JVM)里干活的人,都得看这个印章的脸色。谁敢并发修改共享变量,经理直接一巴掌拍死(阻塞)。

但是,当你把应用拆成微服务,部署到 K8s 集群,启动了 10 个 Pod(也就是 10 个 JVM 进程)时:

  • JVM A 的经理说:“我锁住了!”
  • JVM B 的经理说:“你锁你的,关我屁事?我也锁住了!”
  • JVM C 的经理说:“嘿嘿,我也进来了!”

结果:10 个经理各自为政,100 个线程同时修改数据库里的同一行记录。超卖发生了,数据错乱了,老板的脸绿了。

架构师思考:我们需要一把“全局钥匙”。这把钥匙不能放在任何一台机器上,必须放在一个所有机器都能访问、且互斥性由第三方保证的地方。

生活化比喻

  • 单机锁 = 部门经理的印章(只管自己部门,出了门没人认)。
  • 分布式锁 = 工商局的备案章(全公司、全集群通用。你想盖章?得来工商局排队,一次只能一个人进去)。

 现场复现:当 synchronized 遇上集群

为了更痛彻心扉,咱们先写一段“看似完美”的代码,然后在集群环境下让它“原地爆炸”💥

@RestController
@RequestMapping("/order")
public class OrderController {

    // 悲剧源头:这是一个实例变量,但在集群中,每个 JVM 都有自己的 instance
    private final Object lock = new Object(); 
    
    @Autowired
    private InventoryService inventoryService;

    @PostMapping("/buy")
    public String buy(@RequestParam String productId) {
        // 【致命误区】以为加了 synchronized 就万事大吉
        // 实际上:它只锁住了当前这台服务器的线程!
        // 其他 9 台服务器的线程完全无视这个锁,直接冲进来!
        synchronized (lock) {
            // 1. 查询库存
            int stock = inventoryService.getStock(productId);
            
            if (stock > 0) {
                // 模拟网络延迟,扩大并发窗口,让超卖更容易发生
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                
                // 2. 扣减库存
                inventoryService.deductStock(productId, 1);
                return "下单成功!";
            } else {
                return "库存不足!";
            }
        }
    }
}

场景模拟
假设库存只有 10 个。
此时来了 100 个请求,均匀分布在 10 台服务器上(每台 10 个请求)。

  • 每台服务器内部synchronized 确实保证了 10 个请求串行执行。
  • 但是,10 台服务器是并行执行的
  • 第一毫秒,10 台服务器都查到了库存 10 > 0
  • 第二毫秒,10 台服务器都执行了扣减。
  • 最终结果:库存变成了 0,但实际卖出了 10 * 10 = 100 单!超卖 90 单! 

两大门派登场:Redis vs ZooKeeper

1. Redis 派:天下武功,唯快不破

不成熟的redis

首先大家看一下下面这个有什么问题?

Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value); 
if (success) {
    // 步骤 2: 设置过期时间 (防止死锁)
    redisTemplate.expire(key, timeout, TimeUnit.SECONDS);
    // ... 业务逻辑 ...
    // 步骤 3: 释放锁
    redisTemplate.delete(key);
}
进阶redis——白银时代:lua脚本:原子锁(单机安全)

Lua 脚本在 Redis 服务端是单线程原子执行的

底层原理

将“加锁 + 设过期”合并为一条指令,将“判断所有者 + 删除”合并为一条指令

-- KEYS[1]: 锁的 key
-- ARGV[1]: 唯一标识 (通常是 UUID + ThreadID)
-- ARGV[2]: 过期时间 (毫秒)

if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
    return 1
else
    return 0
end
--Not Exists,只有不存在才设置;PX: 设置毫秒级过期时间


//释放锁:通过比对 Value (UUID),确保“谁加锁,谁解锁”
-- 只有当锁的值等于当前线程的唯一标识时,才删除
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

虽然解决了死锁和误删,但引入了新问题:业务执行时间 > 锁过期时间

  • 场景
    1. 线程 A 加锁,过期时间 30s。
    2. 线程 A 业务卡住了(比如 Full GC,或者下游接口慢),跑了 40s。
    3. 第 30s 时,锁自动过期释放。
    4. 线程 B 趁机加锁成功,开始修改数据。
    5. 第 40s 时,线程 A 跑完了,释放锁(删掉了 B 的锁)。
  • 结果:A 和 B 同时操作数据。互斥性再次失效!
redlock与看门狗watchdog
RedLock 算法 (Redis 官方提出,解决多节点一致性问题)

终极形态、无敌存在(存疑)

  1. 客户端尝试向 N 个独立的 Redis 节点 (通常是 5 个) 依次申请锁。
  2. 只有在 超过半数 (N/2 + 1) 节点上加锁成功,且总耗时小于锁有效期,才算成功。
  3. 如果失败,向所有节点发送释放指令。
  • 代价:性能大幅下降(要网络交互 N 次)。
  • 现状:Martin Kleppmann (分布式系统大神) 曾激烈抨击 RedLock 的安全性。在实际工程中,99% 的公司只用“单机 Redis + 看门狗 + 数据库乐观锁兜底”,很少真上 RedLock。
WatchDog 机制 (Redisson 框架实现,解决单节点超时问题,最常用)
  • Redisson 框架的核心。这个咱们之前也说过,不过主题不一样
  • 如果你不指定 leaseTime (租约时间),Redisson 会自动启动一个后台线程(看门狗)
不推荐场景
  1. 金融核心交易

    • 场景:银行转账、账户扣款。
    • 理由:绝对不能接受“锁丢失”导致的重复扣款。哪怕系统暂停几秒(ZK 选举),也不能账目出错。
  2. 分布式 Master 选举

    • 场景:Hadoop NameNode 选举、Kafka Controller 选举。
    • 理由:必须保证同一时刻只有一个 Master。Redis 的主从切换可能导致“脑裂”(出现两个 Master),这是灾难性的。ZK 的 CP 特性能完美解决。
  3. 配置中心/元数据管理

    • 场景:动态下发全局配置。
    • 理由:配置的一致性至关重要,所有节点必须看到相同的版本。

简易版看门的锁

public class SimpleDistributedLock {

    private final Jedis jedis;
    private final String lockKey;
    private final String requestId; // UUID + ThreadID
    private final int expireTime = 30000; // 30s
    private ScheduledExecutorService scheduler;
    private Future<?> future;

    public SimpleDistributedLock(Jedis jedis, String lockKey) {
        this.jedis = jedis;
        this.lockKey = "lock:" + lockKey;
        this.requestId = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
    }

    // 1. 加锁 (含重试 + 看门狗)
    public boolean lock() {
        while (true) {
            // 尝试加锁 (Lua 脚本保证原子性)
            String script = "if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then return 1 else return 0 end";
            Object result = jedis.eval(script, Collections.singletonList(lockKey), Arrays.asList(requestId, String.valueOf(expireTime)));

            if ("1".equals(result)) {
                // 加锁成功,启动看门狗
                startWatchDog();
                return true;
            }

            // 加锁失败,检查是否是自己的锁 (可重入逻辑简化版,实际需 Hash 结构)
            // 这里为了演示简单,假设不可重入,直接休眠重试
            try { Thread.sleep(100); } catch (InterruptedException e) { return false; }
        }
    }

    // 2. 看门狗核心逻辑
    private void startWatchDog() {
        scheduler = Executors.newSingleThreadScheduledExecutor();
        future = scheduler.scheduleAtFixedRate(() -> {
            // 检查锁是否还存在且属于自己
            String currentVal = jedis.get(lockKey);
            if (requestId.equals(currentVal)) {
                // 续期!
                jedis.pexpire(lockKey, expireTime);
                System.out.println("🐶 WatchDog: 锁已续期 for " + lockKey);
            } else {
                // 锁已经没了(可能是被别人抢了,或者自己释放了),停止看门狗
                stopWatchDog();
            }
        }, expireTime / 3, expireTime / 3, TimeUnit.MILLISECONDS);
    }

    private void stopWatchDog() {
        if (future != null) future.cancel(true);
        if (scheduler != null) scheduler.shutdown();
    }

    // 3. 释放锁
    public void unlock() {
        // 先停看门狗
        stopWatchDog();

        // 再删锁 (Lua 脚本保证只删自己的)
        String script = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end";
        jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
    }
}
  1. requestId 的构成UUID + ThreadID。这是为了区分不同 JVM 甚至同一 JVM 的不同线程。
  2. scheduleAtFixedRate:每 10 秒执行一次。为什么是 10 秒?因为过期是 30 秒。留 20 秒的网络缓冲时间,防止网络抖动导致续期指令没赶到锁就过期了。
  3. Lua 脚本的位置:注意 eval 命令。所有逻辑都在 Redis 服务端执行,避免了网络往返带来的竞态条件。

看门狗底层工作流程

  1. 初始加锁:默认过期时间 30s。
  2. 启动定时任务:每隔 30s / 3 = 10s,检查一次。
  3. 续期 (Renew):如果当前线程还持有锁,看门狗会把过期时间重新重置为 30s
  4. 停止:一旦业务结束或线程崩溃,看门狗停止续期,锁在 30s 后自然失效。
// Redisson 内部逻辑简化版
private void renewExpiration() {
    ee.scheduleAtFixedRate(() -> {
        Long ttl = getTTL(key);
        if (ttl > 0) {
            // 只要锁还在,就续命到 30s
            redisTemplate.expire(key, 30, TimeUnit.SECONDS); 
        }
    }, 10, 10, TimeUnit.SECONDS);
}
  • 优点:只要机器不宕机,锁永远不会因为“超时”而意外释放。
  • 缺点:如果客户端直接断电/宕机,看门狗线程也死了,无法续期,锁会在 30s 后释放(这是合理的故障恢复机制)。
锁类型 底层实现原理 适用场景
互斥锁 (Mutex) SETNX + Value 校验 + WatchDog 最常用,保证同一时刻只有一个线程执行。
可重入锁 (Reentrant) Hash 结构:Key -> {ThreadID: Count}
每次加锁 Count+1,释放 Count-1,归零才删 Key。
同一个线程内递归调用方法,不会死锁自己。Redisson 默认支持。
读写锁 (ReadWriteLock) 两个 Key:lock:read (计数器), lock:write (互斥)。
读锁共享,写锁互斥。
读多写少场景(如缓存更新)。性能高于互斥锁。
联锁 (MultiLock) 同时获取多个资源的锁(如同时锁住 A 产品和 B 产品)。
底层是遍历获取一组锁,全部成功才算成功。
涉及多个资源原子操作的场景。
红锁 (RedLock) 向 N/2+1 个独立节点加锁。 对数据一致性要求极高,且能接受性能损耗的场景(极少用)。

2. ZooKeeper 派:稳如老狗,数据至上

  • Redis 倾向于 AP (Availability + Partition Tolerance):网络分区时,优先保证服务可用,可能牺牲一致性(锁丢失)。
  • ZooKeeper 倾向于 CP (Consistency + Partition Tolerance):网络分区时,优先保证数据强一致,宁可暂时不可用(选举期间),也不能给错结果。
  • Redis:基于内存复制(Async Replication)。主节点写完,异步传给从节点。主挂了,从节点可能还没收到数据就上位了 -> 锁丢了
  • ZooKeeper:基于 Zab 协议 (类似 Paxos)。任何写操作(创建节点)必须经过半数以上节点确认才算成功。只要集群过半数活着,数据就绝对不会丢

“Redis 锁是在赌概率(赌主从不同时挂),ZK 锁是在信数学(只要过半数节点在,锁就在)。”

底层原理

临时节点:之前咱也说过,复习一下吧

  • ZK 实现分布式锁,不需要像 Redis 那样搞复杂的 Lua 脚本或看门狗,它利用了两个天然的原子特性

特性 A:临时节点 (Ephemeral Node)

  • 定义:节点的生命周期与客户端会话 (Session) 绑定。
  • 效果:如果客户端宕机、网络断开(Session 过期),ZK 服务器会自动删除该节点。
  • 解决痛点彻底杜绝死锁。不需要“看门狗”去续期,客户端死了,锁自动释放。

特性 B:顺序节点 (Sequential Node)

  • 定义:创建节点时,ZK 会自动在名字后面追加一个单调递增的序列号、很巧妙。
    • 例如:/locks/order-000000001/locks/order-000000002...
  • 效果:天然形成排队队列
  • 解决痛点避免“羊群效应” (Herd Effect)。不需要所有线程轮询抢锁,只需监听前一个节点即可。
不推荐场景:
  1. 不要用于高频短锁

    • 如果你有一个接口每秒调用 1 万次,每次都要加锁 10ms,千万别用 ZK。ZK 的写入 TPS 通常只有几千,会成为整个系统的瓶颈,甚至把 ZK 集群打挂,拖垮所有依赖 ZK 的服务(如 Dubbo 注册中心)。
    • 对策:这种场景请用 Redis。
  2. 会话超时时间 (Session Timeout)

    • ZK 锁的释放依赖于 Session 过期。
    • 如果设置太短(如 3s),网络轻微抖动就会导致锁被误删(其他节点以为你挂了)。
    • 如果设置太长(如 60s),客户端真挂了,锁要 60s 后才释放,系统可用性降低。
    • 建议:通常设置为 15s - 30s,配合合理的重试机制。
上锁的过程

假设我们要锁住资源 product_1001,路径为 /locks/product_1001

  1. 创建节点

    • 客户端 A 调用 create("/locks/product_1001/", data, EPHEMERAL | SEQUENTIAL)
    • ZK 返回完整路径:/locks/product_1001/node-001
    • 客户端 B 同样操作,得到:/locks/product_1001/node-002
    • 客户端 C 得到:/locks/product_1001/node-003
  2. 判断锁归属

    • 客户端获取目录下所有子节点,排序。
    • 规则:谁创建的节点序号最小,谁就获得锁。
    • 此时,node-001 (A) 最小 -> A 获得锁
  3. 等待机制 (Watch)

    • B 发现自己不是最小(002 > 001),它不会傻乎乎地循环轮询(那是浪费 CPU)。
    • B 会向 ZK 注册一个 Watcher,监听 前一个节点 (node-001) 的删除事件
    • C 监听 node-002 的删除事件。
    • 状态:B 和 C 进入阻塞等待(挂起),不消耗 CPU。
  4. 释放与传递

    • A 业务执行完毕,调用 delete("/locks/product_1001/node-001")
    • 或者 A 宕机了,Session 过期,ZK 自动删除 node-001
    • 触发:ZK 通知 B:“你监听的那个节点没了!”
    • B 被唤醒,再次检查目录,发现自己变成了最小 (node-002) -> B 获得锁
    • B 执行完后删除自己,唤醒 C。

实战代码:如何实现?

Redis 方案 (推荐:Redisson)

千万别自己手写 setnx + expire,很容易写出死锁或锁提前过期的 Bug。请直接使用 Redisson,它封装了看门狗(WatchDog)机制,自动续期;

// 依赖:org.redisson:redisson-spring-boot-starter
@Autowired
private RedissonClient redissonClient;

public void buyWithRedis(String productId) {
    // 获取锁,key 必须全局唯一,通常用 "lock:product:" + id
    RLock lock = redissonClient.getLock("lock:product:" + productId);
    
    // 尝试加锁:
    // waitTime: 等待时间 (比如 5 秒,抢不到就放弃)
    // leaseTime: 租约时间 (-1 表示启用看门狗,自动续期,直到业务执行完)
    boolean isLocked = false;
    try {
        isLocked = lock.tryLock(5, -1, TimeUnit.SECONDS);
        if (isLocked) {
            // 【临界区】现在全集群只有我能进这里!
            int stock = inventoryService.getStock(productId);
            if (stock > 0) {
                Thread.sleep(100); // 模拟业务
                inventoryService.deductStock(productId, 1);
            }
        } else {
            // 获取锁失败,说明太忙了,直接返回或降级
            throw new BusinessException("系统繁忙,请稍后重试");
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("锁被中断", e);
    } finally {
        // 只有持有锁才能释放,且要检查是否还是当前线程持有的(防止误删别人的锁)
        if (isLocked && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

ZooKeeper 方案 (Curator 框架)

永远不要手写原生 ZK 锁逻辑,请使用 Apache Curator!

// 依赖:org.apache.curator:curator-recipes
@Autowired
private CuratorFramework client;

public void buyWithZK(String productId) throws Exception {
    String lockPath = "/locks/product/" + productId;
    
    // 创建互斥锁
    InterProcessMutex lock = new InterProcessMutex(client, lockPath);
    
    // 尝试获取锁 (带超时)
    if (lock.acquire(5, TimeUnit.SECONDS)) {
        try {
            // 【临界区】ZK 保证全集群强一致
            int stock = inventoryService.getStock(productId);
            if (stock > 0) {
                Thread.sleep(100);
                inventoryService.deductStock(productId, 1);
            }
        } finally {
            // 释放锁
            lock.release();
        }
    } else {
        throw new BusinessException("系统繁忙,获取锁超时");
    }
}
  1. 会话管理:Curator 自动处理 ZK 连接断开后的重连 (Retry Policy)。
  2. Watcher 注册acquire() 方法内部自动帮你查找当前最小节点,如果不是自己,自动注册 Watcher 监听前驱节点。
  3. 异常处理:如果网络抖动导致 Watcher 丢失,Curator 会重新查询并注册,保证逻辑闭环。
维度 Redis 分布式锁 (Redisson) ZooKeeper 分布式锁 (Curator)
一致性模型 AP (最终一致性)
风险:主从切换瞬间可能丢锁
CP (强一致性)
优势:只要集群存活,锁绝不丢失
实现原理 SETNX + Lua 脚本 + 看门狗续期 临时顺序节点 + Watcher 监听
死锁处理 依赖看门狗持续续期。
若客户端宕机,看门狗停,锁自动过期
依赖Session 机制。
若客户端宕机,ZK 服务端直接删节点
性能 (QPS) 极高 (内存操作,微秒级)
适合高并发秒杀
较低 (涉及磁盘同步、网络交互、毫秒级)
不适合高频争抢
羊群效应 需要客户端轮询或 RedLock 复杂逻辑 天然避免 (只监听前一个节点)
运维成本 低 (很多公司已有 Redis) 高 (需维护 ZK 集群,对运维要求高)
适用场景 90% 场景:秒杀、抢购、普通订单 10% 场景:金融转账、元数据管理、Master 选举

架构师选型决策树:到底选谁

    • 核心原理:利用 Redis 的 SETNX (Set If Not Exists) 命令。
    • 模型AP 模型 (Availability + Partition tolerance)。优先保证高可用,极端情况下可能丢失锁(主从切换瞬间),但概率极低。
    • 特点
      • :基于内存,性能极高。
      • 简单:客户端实现容易(配合 Lua 脚本或 RedLock 算法)。
      • 风险:如果 Redis 主节点挂了,锁还没同步到从节点,新主节点可能不知道锁的存在,导致锁失效(虽然可以通过 RedLock 缓解,但复杂度上升)。
    1. setIfAbsent (SETNX) 和 expire 是两条独立的 TCP 指令:非原子性
    2. 两条线程误删锁,a把b的锁删了:这个很简单,具体原因不说了
    • 核心原理:利用 ZK 的临时顺序节点
    • 模型CP 模型 (Consistency + Partition tolerance)。优先保证强一致性。
    • 特点
      • :基于 Paxos/Zab 协议,只要集群过半数节点存活,数据就绝对一致。锁绝对不会丢。
      • 监听机制:客户端可以 Watch 前一个节点,前一个释放了,立刻通知下一个,无需轮询。
      • :每次加锁都要创建节点、网络交互,性能比 Redis 差一个数量级。
      • :ZK 集群维护成本高,不适合高并发频繁加锁的场景。
咱们再次强调一下:
  1. 90% 的互联网场景(电商秒杀、抢券、普通订单)

    • 选 Redis (Redisson)
    • 理由:性能是第一生命线。Redis 的 QPS 能轻松扛住几万甚至几十万。偶尔极小概率的锁丢失(主从切换瞬间),可以通过数据库乐观锁 (version 字段) 做最后一道防线来兜底。
    • 心态:允许极小概率的失败重试,换取极致吞吐量。
  2. 10% 的核心金融/强一致场景(银行转账、分布式事务协调、元数据管理)

    • 选 ZooKeeper
    • 理由:数据一致性高于一切。哪怕慢一点,哪怕吞吐量低一点,也绝对不能出现“锁丢了导致重复扣款”的情况。ZK 的 CP 特性能给你兜底。
    • 心态:宁可系统慢,不可数据错。
  3. 特殊场景

    • 如果你的基础设施里只有 Redis 没有 ZK,别为了个锁专门搭一套 ZK 集群,运维成本太高。用 Redis + 数据库乐观锁足矣。
    • 如果你已经在重度使用 ZK 做注册中心(如 Dubbo 老版本),且并发量不大,顺手用 ZK 做锁也无可厚非

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐