分布式锁模式:从单机到集群的锁进化之路
分布式锁,说白了就是让多个服务节点「排队干活」的机制。我刚开始做分布式系统时,总觉得这玩意儿跟单机锁差不多,直到线上出了数据不一致的故障……嗯,那次教训让我彻底明白了分布式锁的复杂性。
为什么需要分布式锁?
想象一下:你有一个订单号生成服务,部署了3个节点。如果不用锁,两个节点可能生成相同的订单号。单机锁(比如synchronized)只能锁住当前JVM,跨节点就无能为力了。
分布式锁要解决三个核心问题:
- 互斥性:同一时刻只有一个节点能拿到锁
- 安全性:锁不会被其他节点误删
- 高可用:锁服务挂了,系统还能正常工作
我个人习惯把分布式锁分为三类:数据库锁、Redis锁、ZooKeeper锁。每种都有适用场景,没有银弹。
数据库锁:最朴素的实现
用数据库实现分布式锁,思路很简单:利用唯一索引或行锁。
-- 创建锁表
CREATE TABLE distributed_lock (
lock_name VARCHAR(64) PRIMARY KEY,
owner VARCHAR(64) NOT NULL,
expire_time BIGINT NOT NULL
);
-- 获取锁(插入成功即获得锁)
INSERT INTO distributed_lock(lock_name, owner, expire_time)
VALUES('order_lock', 'node1', 1700000000);
-- 释放锁(删除自己的记录)
DELETE FROM distributed_lock
WHERE lock_name='order_lock' AND owner='node1';
我在项目中遇到过一个问题:数据库锁的性能瓶颈很明显。高并发下,行锁竞争会导致大量等待,甚至死锁。而且数据库单点故障时,整个系统就瘫痪了。
注意:数据库锁一定要设置过期时间!我曾经见过一个服务挂了,锁记录没删,结果所有节点都拿不到锁,线上告警响了一整夜……
Redis锁:高性能的选择
Redis锁是目前最流行的方案。核心命令是SET NX EX,原子操作,性能极高。
// 获取锁
String result = jedis.set("lock:order", "node1",
SetParams.setParams().nx().ex(30));
if ("OK".equals(result)) {
// 执行业务逻辑
try {
// ...
} finally {
// 释放锁(用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, 1, "lock:order", "node1");
}
}
你想想看,这里有个坑:如果业务执行时间超过锁的过期时间,锁自动释放了,其他节点就能拿到锁。这时候第一个节点执行完,把别人的锁给删了……
解决方案是「续期」机制。我建议用Redisson的看门狗(Watch Dog),它会自动续期,默认每10秒检查一次。
ZooKeeper锁:强一致性方案
ZooKeeper锁利用临时顺序节点,实现公平锁。原理是:所有节点在同一个目录下创建临时顺序节点,序号最小的获得锁。
// 伪代码
public class ZkLock {
private ZooKeeper zk;
private String lockPath = "/locks/order";
public void lock() throws Exception {
// 创建临时顺序节点
String node = zk.create(lockPath + "/lock-",
new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL);
// 获取所有子节点,判断自己是不是最小的
List<String> children = zk.getChildren(lockPath, false);
Collections.sort(children);
if (node.equals(lockPath + "/" + children.get(0))) {
return; // 获得锁
}
// 监听前一个节点
String prevNode = children.get(children.indexOf(node) - 1);
CountDownLatch latch = new CountDownLatch(1);
zk.exists(lockPath + "/" + prevNode, event -> latch.countDown());
latch.await(); // 等待前一个节点释放
}
}
ZooKeeper锁的好处是:客户端断开连接时,临时节点自动删除,不会死锁。但性能不如Redis,适合对一致性要求极高的场景。
Redisson:开箱即用的分布式锁
Redisson是Redis官方推荐的Java客户端,封装了各种分布式锁的实现。我个人最喜欢它的RLock接口,用起来跟ReentrantLock一模一样。
// 引入依赖
// <dependency>
// <groupId>org.redisson</groupId>
// <artifactId>redisson-spring-boot-starter</artifactId>
// <version>3.20.0</version>
// </dependency>
@Autowired
private RedissonClient redisson;
public void processOrder() {
RLock lock = redisson.getLock("order:lock");
try {
// 尝试获取锁,最多等待10秒,锁30秒后自动释放
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
// 执行业务逻辑
System.out.println("拿到锁了,处理订单...");
Thread.sleep(5000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
Redisson的看门狗机制很贴心:默认每10秒检查一次,如果业务还没执行完,就自动续期30秒。这样就不用担心业务超时导致锁提前释放了。
三种方案对比
| 特性 | 数据库锁 | Redis锁 | ZooKeeper锁 |
|---|---|---|---|
| 性能 | 低 | 高 | 中 |
| 一致性 | 强 | 最终一致 | 强 |
| 可用性 | 低(单点) | 高(集群) | 高(集群) |
| 实现复杂度 | 简单 | 中等 | 复杂 |
| 适用场景 | 低并发、小项目 | 高并发、可接受短暂不一致 | 强一致性、金融场景 |
避坑指南
我曾经踩过几个坑,分享出来供你参考:
- 锁的粒度要细:别锁整个订单表,锁单个订单ID就行。粒度越细,并发越高。
- 一定要设置超时:不管是数据库还是Redis,都要设置锁的过期时间。防止客户端挂了,锁永远不释放。
- 释放锁要校验身份:用Lua脚本或Redisson的isHeldByCurrentThread,确保只释放自己的锁。
- 考虑时钟漂移:Redis锁依赖服务器时间,如果两台服务器时间差太大,锁可能失效。建议用NTP同步时间。
我的经验是:中小项目用Redis锁就够了,配合Redisson几乎不用操心细节。如果涉及资金交易,建议用ZooKeeper锁,虽然慢点,但安全第一。
核心知识体系
下面这张图总结了分布式锁的核心逻辑,我画了很久,希望能帮你理清思路:
分布式锁没有完美的方案,关键是根据业务场景做取舍。我个人建议:能用Redis就用Redis,配合Redisson能省很多事。如果对一致性要求极高,再考虑ZooKeeper。
记住一句话:锁是手段,不是目的。能用无锁设计解决的问题,尽量别上锁。分布式锁带来的复杂度,有时候比它解决的问题还多。