微服务时代“通行证”:为什么 synchronized 救不了集群
各位架构师、后端大佬们,大家好。
今天我们要聊一个让无数英雄折戟沉沙的“坑”:分布式锁。
很多刚把单体应用拆成微服务的同学,是不是会遭遇一种“灵异事件”:
“我在本地测试好好的,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
虽然解决了死锁和误删,但引入了新问题:业务执行时间 > 锁过期时间。
- 场景:
- 线程 A 加锁,过期时间 30s。
- 线程 A 业务卡住了(比如 Full GC,或者下游接口慢),跑了 40s。
- 第 30s 时,锁自动过期释放。
- 线程 B 趁机加锁成功,开始修改数据。
- 第 40s 时,线程 A 跑完了,释放锁(删掉了 B 的锁)。
- 结果:A 和 B 同时操作数据。互斥性再次失效!
redlock与看门狗watchdog
RedLock 算法 (Redis 官方提出,解决多节点一致性问题)
终极形态、无敌存在(存疑)
- 客户端尝试向 N 个独立的 Redis 节点 (通常是 5 个) 依次申请锁。
- 只有在 超过半数 (N/2 + 1) 节点上加锁成功,且总耗时小于锁有效期,才算成功。
- 如果失败,向所有节点发送释放指令。
- 代价:性能大幅下降(要网络交互 N 次)。
- 现状:Martin Kleppmann (分布式系统大神) 曾激烈抨击 RedLock 的安全性。在实际工程中,99% 的公司只用“单机 Redis + 看门狗 + 数据库乐观锁兜底”,很少真上 RedLock。
WatchDog 机制 (Redisson 框架实现,解决单节点超时问题,最常用)
- Redisson 框架的核心。这个咱们之前也说过,不过主题不一样
- 如果你不指定
leaseTime(租约时间),Redisson 会自动启动一个后台线程(看门狗)
不推荐场景
-
金融核心交易:
- 场景:银行转账、账户扣款。
- 理由:绝对不能接受“锁丢失”导致的重复扣款。哪怕系统暂停几秒(ZK 选举),也不能账目出错。
-
分布式 Master 选举:
- 场景:Hadoop NameNode 选举、Kafka Controller 选举。
- 理由:必须保证同一时刻只有一个 Master。Redis 的主从切换可能导致“脑裂”(出现两个 Master),这是灾难性的。ZK 的 CP 特性能完美解决。
-
配置中心/元数据管理:
- 场景:动态下发全局配置。
- 理由:配置的一致性至关重要,所有节点必须看到相同的版本。
简易版看门的锁
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));
}
}
requestId的构成:UUID + ThreadID。这是为了区分不同 JVM 甚至同一 JVM 的不同线程。scheduleAtFixedRate:每 10 秒执行一次。为什么是 10 秒?因为过期是 30 秒。留 20 秒的网络缓冲时间,防止网络抖动导致续期指令没赶到锁就过期了。- Lua 脚本的位置:注意
eval命令。所有逻辑都在 Redis 服务端执行,避免了网络往返带来的竞态条件。
看门狗底层工作流程
- 初始加锁:默认过期时间 30s。
- 启动定时任务:每隔
30s / 3 = 10s,检查一次。 - 续期 (Renew):如果当前线程还持有锁,看门狗会把过期时间重新重置为 30s。
- 停止:一旦业务结束或线程崩溃,看门狗停止续期,锁在 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 万次,每次都要加锁 10ms,千万别用 ZK。ZK 的写入 TPS 通常只有几千,会成为整个系统的瓶颈,甚至把 ZK 集群打挂,拖垮所有依赖 ZK 的服务(如 Dubbo 注册中心)。
- 对策:这种场景请用 Redis。
-
会话超时时间 (Session Timeout):
- ZK 锁的释放依赖于 Session 过期。
- 如果设置太短(如 3s),网络轻微抖动就会导致锁被误删(其他节点以为你挂了)。
- 如果设置太长(如 60s),客户端真挂了,锁要 60s 后才释放,系统可用性降低。
- 建议:通常设置为 15s - 30s,配合合理的重试机制。
上锁的过程
假设我们要锁住资源 product_1001,路径为 /locks/product_1001
-
创建节点:
- 客户端 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。
- 客户端 A 调用
-
判断锁归属:
- 客户端获取目录下所有子节点,排序。
- 规则:谁创建的节点序号最小,谁就获得锁。
- 此时,
node-001(A) 最小 -> A 获得锁。
-
等待机制 (Watch):
- B 发现自己不是最小(002 > 001),它不会傻乎乎地循环轮询(那是浪费 CPU)。
- B 会向 ZK 注册一个 Watcher,监听 前一个节点 (
node-001) 的删除事件。 - C 监听
node-002的删除事件。 - 状态:B 和 C 进入阻塞等待(挂起),不消耗 CPU。
-
释放与传递:
- A 业务执行完毕,调用
delete("/locks/product_1001/node-001")。 - 或者 A 宕机了,Session 过期,ZK 自动删除
node-001。 - 触发:ZK 通知 B:“你监听的那个节点没了!”
- B 被唤醒,再次检查目录,发现自己变成了最小 (
node-002) -> B 获得锁。 - B 执行完后删除自己,唤醒 C。
- A 业务执行完毕,调用
实战代码:如何实现?
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("系统繁忙,获取锁超时");
}
}
- 会话管理:Curator 自动处理 ZK 连接断开后的重连 (Retry Policy)。
- Watcher 注册:
acquire()方法内部自动帮你查找当前最小节点,如果不是自己,自动注册 Watcher 监听前驱节点。 - 异常处理:如果网络抖动导致 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 缓解,但复杂度上升)。
setIfAbsent(SETNX) 和expire是两条独立的 TCP 指令:非原子性- 两条线程误删锁,a把b的锁删了:这个很简单,具体原因不说了
- 核心原理:利用 ZK 的临时顺序节点。
- 模型:CP 模型 (Consistency + Partition tolerance)。优先保证强一致性。
- 特点:
- 稳:基于 Paxos/Zab 协议,只要集群过半数节点存活,数据就绝对一致。锁绝对不会丢。
- 监听机制:客户端可以 Watch 前一个节点,前一个释放了,立刻通知下一个,无需轮询。
- 慢:每次加锁都要创建节点、网络交互,性能比 Redis 差一个数量级。
- 重:ZK 集群维护成本高,不适合高并发频繁加锁的场景。
- 核心原理:利用 Redis 的
咱们再次强调一下:
-
90% 的互联网场景(电商秒杀、抢券、普通订单):
- 选 Redis (Redisson)。
- 理由:性能是第一生命线。Redis 的 QPS 能轻松扛住几万甚至几十万。偶尔极小概率的锁丢失(主从切换瞬间),可以通过数据库乐观锁 (
version字段) 做最后一道防线来兜底。 - 心态:允许极小概率的失败重试,换取极致吞吐量。
-
10% 的核心金融/强一致场景(银行转账、分布式事务协调、元数据管理):
- 选 ZooKeeper。
- 理由:数据一致性高于一切。哪怕慢一点,哪怕吞吐量低一点,也绝对不能出现“锁丢了导致重复扣款”的情况。ZK 的 CP 特性能给你兜底。
- 心态:宁可系统慢,不可数据错。
-
特殊场景:
- 如果你的基础设施里只有 Redis 没有 ZK,别为了个锁专门搭一套 ZK 集群,运维成本太高。用 Redis + 数据库乐观锁足矣。
- 如果你已经在重度使用 ZK 做注册中心(如 Dubbo 老版本),且并发量不大,顺手用 ZK 做锁也无可厚非
更多推荐

所有评论(0)