在分布式系统中,多个服务实例同时访问共享资源时,如何保证数据一致性是个经典难题。去年我们电商系统就遇到过这样的场景:大促期间库存扣减出现超卖,事后排查发现正是由于多个节点同时执行了库存检查-扣减操作。这时候就需要分布式锁登场了——它就像跨机房的服务员手中的对讲机,确保同一时刻只有一个节点能操作关键资源。
分布式锁与单机锁的本质区别在于其工作环境:
这些特性决定了我们不能简单地把Java的synchronized或ReentrantLock直接搬到分布式环境。下面这张对比表能清晰展示差异:
| 特性 | 单机锁 | 分布式锁 |
|---|---|---|
| 作用域 | 单个JVM进程内 | 跨网络的多节点 |
| 性能损耗 | 纳秒级 | 毫秒级 |
| 故障影响 | 进程崩溃自动释放 | 需要额外机制处理 |
| 实现复杂度 | JDK内置 | 需要外部组件协同 |
最早期的方案是直接利用数据库的唯一约束特性。我们在MySQL创建lock表:
sql复制CREATE TABLE `distributed_lock` (
`lock_key` varchar(64) NOT NULL,
`expire_time` datetime NOT NULL,
PRIMARY KEY (`lock_key`)
) ENGINE=InnoDB;
获取锁的SQL示例:
sql复制INSERT INTO distributed_lock(lock_key, expire_time)
VALUES ('order_lock', NOW() + INTERVAL 30 SECOND)
ON DUPLICATE KEY UPDATE
expire_time = IF(expire_time < NOW(), VALUES(expire_time), expire_time);
这种方案的问题在于:
关键提示:如果必须用数据库方案,建议单独使用一个物理库,避免影响业务数据库性能
最基础的Redis锁实现:
bash复制SET lock:order 1 NX EX 30
但这个方案存在致命缺陷——在Redis主从切换时可能导致锁失效。我们曾经因此损失过百万级的订单数据。改进方案需要增加唯一标识:
java复制String clientId = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent("lock:order", clientId, 30, TimeUnit.SECONDS);
// 释放锁时需要校验持有者
if(clientId.equals(redisTemplate.opsForValue().get("lock:order"))){
redisTemplate.delete("lock:order");
}
Redis官方推荐的分布式锁算法,核心流程:
我们在生产环境使用5节点集群时,发现机房网络分区会导致算法失效。更稳妥的做法是配合ZooKeeper的watch机制做二次校验。
ZooKeeper通过临时顺序节点实现锁:
java复制public void tryLock() throws Exception {
// 创建临时有序节点
currentPath = zk.create("/locks/order_",
null,
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL);
// 获取所有兄弟节点
List<String> children = zk.getChildren("/locks", false);
// 排序并判断自己是否是最小节点
Collections.sort(children);
if(currentPath.equals("/locks/" + children.get(0))){
// 获取锁成功
} else {
// 注册watcher监听前一个节点
}
}
这种方案的优点是:
但ZK的写性能不如Redis,我们在压测时发现万级并发下平均延迟达到15ms,不适合高频锁场景。
我们曾经因为GC停顿导致锁过期,引发多个节点同时持有锁。现在的解决方案是:
java复制private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
void startLockRenewal() {
scheduler.scheduleAtFixedRate(() -> {
if(lockHolder.equals(clientId)){
// 只续期剩余时间超过10%的锁
long ttl = redisTemplate.getExpire(lockKey);
if(ttl > 3000) { // 30秒的锁还剩3秒时续期
redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS);
}
}
}, 10, 10, TimeUnit.SECONDS); // 每10秒检查一次
}
血泪教训:续期线程必须用daemon模式,否则应用关闭时可能无法正常退出
直接使用while循环获取锁会导致Redis QPS暴涨。我们现在的做法是:
核心代码片段:
java复制RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.addMessageListener((message, pattern) -> {
// 收到锁释放通知后尝试获取
tryLock();
}, new ChannelTopic("lock_released:" + lockKey));
// 退避算法
long waitTime = baseWaitTime * (long)Math.pow(2, retryCount);
Thread.sleep(waitTime + random.nextInt(100));
我们在同等硬件环境下测试不同方案的极限性能:
| 方案 | 吞吐量(QPS) | 平均延迟(ms) | 资源消耗 |
|---|---|---|---|
| MySQL行锁 | 1,200 | 8.5 | CPU 70% |
| Redis单节点 | 18,000 | 2.1 | 内存3GB |
| RedLock(5节点) | 6,500 | 15.4 | 网络30Mbps |
| ZooKeeper集群 | 9,800 | 5.7 | 磁盘IO高 |
实际选型建议:
我们曾经把整个订单系统用一个全局锁保护,结果并发能力降到每秒几十个请求。现在遵循的原则是:
按业务维度拆分
锁分段技术
java复制// 将原来全局的inventory_lock拆分为16个段
int segment = skuId.hashCode() & 0xF;
String lockKey = "inventory_lock_seg_" + segment;
redis复制# 读锁(共享锁)
SET read_lock:order:1234 1 NX EX 30
# 写锁(排他锁)
SET write_lock:order:1234 1 NX EX 30
这种设计使我们的库存服务TPS从500提升到8000+。关键是要用Guava的Striped类实现轻量级分段:
java复制Striped<Lock> stripedLocks = Striped.lock(32);
Lock segmentLock = stripedLocks.get(orderId);
segmentLock.lock();
try {
// 处理业务
} finally {
segmentLock.unlock();
}