第 228 章:分布式锁
学习目标
- 理解分布式锁需求
- 掌握 Redis + Redisson 实现
- 了解 ZooKeeper 锁
- 学会 MySQL 乐观锁
一、为什么需要分布式锁
场景:
- 同一用户被两台服务同时扣款(余额超扣)
- 库存同时被两台机器减,导致负数
- 定时任务多实例同时执行,重复扣款
二、分布式锁要求
| 维度 | 要求 |
|---|---|
| 互斥性 | 任意时刻只一个客户端持锁 |
| 不死锁 | 持锁客户端崩溃,锁可释放 |
| 可重入 | 同一线程可多次获取同锁 |
| 谁加谁解 | 不能解别人锁 |
| 可等待 | 拿不到可选择阻塞或立即返回 |
三、Redis 分布式锁
3.1 SET NX 原理
bash
SET key value NX PX 30000NX:不存在才设置PX:过期时间(毫秒)
java
public class SimpleRedisLock {
private final StringRedisTemplate redis;
public boolean tryLock(String key, String value, long expireMs) {
Boolean ok = redis.opsForValue()
.setIfAbsent(key, value, Duration.ofMillis(expireMs));
return Boolean.TRUE.equals(ok);
}
// 用 Lua 脚本保证"检查并删除"原子性
private static final String UNLOCK_LUA = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
""";
public void unlock(String key, String value) {
redis.execute(
RedisScript.of(UNLOCK_LUA, Long.class),
Collections.singletonList(key),
value
);
}
}3.2 价值(value)的意义
解决:value 用唯一标识(线程 UUID + 时间戳),解锁前 Lua 检查。
3.3 Redisson(推荐)
引入依赖
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.4</version>
</dependency>配置
yaml
spring:
redis:
host: 127.0.0.1
port: 6379
redisson:
address: redis://127.0.0.1:6379
password: ${REDIS_PASS}
database: 0使用
java
@Autowired
private RedissonClient redisson;
public void deductStock(String skuId) {
RLock lock = redisson.getLock("stock:lock:" + skuId);
try {
// 等待 100 ms,持锁 30 秒自动释放
boolean acquired = lock.tryLock(100, 30000, TimeUnit.MILLISECONDS);
if (!acquired) {
throw new BizException("系统繁忙,请稍后再试");
}
// 业务逻辑(锁内)
int stock = inventoryRepo.getStock(skuId);
if (stock <= 0) {
throw new BizException("库存不足");
}
inventoryRepo.deduct(skuId, 1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 必须解锁,只在持锁线程中解锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}Watch Dog 自动续期
java
// 默认 lockWatchdogTimeout = 30 秒
// 每 10 秒自动续期到 30 秒
// 业务未完成锁不会失效
lock.tryLock(0, 30000, TimeUnit.MILLISECONDS); // 显示指定则关 WatchDog可重入
同线程可多次加锁,计数 +1:
java
public void a() {
lock.lock();
b();
lock.unlock();
}
public void b() {
lock.lock(); // 可重入,成功
lock.unlock();
}3.4 公平锁 / 读锁 / 联锁
java
// 公平锁:按等待顺序加锁
RLock fairLock = redisson.getFairLock("lock");
// 读写锁
RReadWriteLock rwLock = redisson.getReadWriteLock("rw");
RLock readLock = rwLock.readLock();
RLock writeLock = rwLock.writeLock();
// 联锁:多把锁同时加
RLock lock1 = redisson.getLock("a");
RLock lock2 = redisson.getLock("b");
RedissonMultiLock multiLock = redisson.getMultiLock(lock1, lock2);
// 任一失败,所有释放
// 红锁:MultiLock + Redis Sentinel/Cluster3.5 RedLock(多 Redis 节点)
java
RLock lock1 = redisson1.getLock("lock");
RLock lock2 = redisson2.getLock("lock");
RLock lock3 = redisson3.getLock("lock");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
// 多数节点加锁成功 → 真正加锁成功
redLock.lock();
try {
// ...
} finally {
redLock.unlock();
}四、ZooKeeper 分布式锁
4.1 原理
利用临时顺序节点 + 监听。
bash
# 1. 客户端 A 在 /lock 下创建临时顺序节点 → /lock/node-001
# 2. 客户端 B 创建 → /lock/node-002
# 3. B 拿到所有子节点,判断自己不是最小 → 监听比自己小一号的节点(node-001)
# 4. node-001 删除(释放锁) → 触发 B 的监听
# 5. B 重新检查,自己成最小 → 获锁4.2 Curator
xml
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
</dependency>java
public class ZkLockDemo {
public static void main(String[] args) throws Exception {
RetryPolicy retry = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString("127.0.0.1:2181")
.retryPolicy(retry)
.build();
client.start();
// 互斥锁
InterProcessMutex mutex = new InterProcessMutex(client, "/mylock");
if (mutex.acquire(5, TimeUnit.SECONDS)) {
try {
System.out.println("获锁");
Thread.sleep(5000);
} finally {
mutex.release();
}
}
}
}4.3 ZK 锁 vs Redis 锁
| 维度 | Redis | ZK |
|---|---|---|
| 性能 | 高 | 中 |
| 可靠性 | 主从切换可能丢锁 | ZAB 协议强一致 |
| 复杂度 | 低 | 中 |
| 锁粒度 | 不公平 | 公平(FIFO) |
| 适用 | 高性能场景 | 强一致场景 |
五、MySQL 分布式锁
5.1 基于唯一索引
sql
CREATE TABLE distributed_lock (
id BIGINT PRIMARY KEY,
lock_key VARCHAR(64) UNIQUE,
holder VARCHAR(128),
expire_at TIMESTAMP
);java
public boolean tryLock(String key, String holder, long ttlMs) {
int rows = jdbc.update(
"INSERT IGNORE INTO distributed_lock(lock_key, holder, expire_at) " +
"VALUES(?, ?, NOW() + INTERVAL ? SECOND)",
key, holder, ttlMs / 1000
);
return rows > 0;
}
public boolean unlock(String key, String holder) {
return jdbc.update(
"DELETE FROM distributed_lock WHERE lock_key = ? AND holder = ?",
key, holder
) > 0;
}5.2 乐观锁
sql
UPDATE inventory
SET stock = stock - 1,
version = version + 1
WHERE sku_id = ?
AND stock > 0
AND version = ? -- 乐观锁,失败时重试java
public boolean deduct(String skuId) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
Inventory inv = inventoryRepo.findBySku(skuId);
if (inv == null || inv.getStock() <= 0) return false;
int rows = jdbc.update(
"UPDATE inventory SET stock = stock - 1, version = version + 1 " +
"WHERE sku_id = ? AND version = ?",
skuId, inv.getVersion()
);
if (rows > 0) return true;
}
return false;
}5.3 SELECT FOR UPDATE
java
@Transactional
public void deductStock(String skuId, int qty) {
// 悲观锁:行锁
Inventory inv = jdbc.queryForObject(
"SELECT * FROM inventory WHERE sku_id = ? FOR UPDATE",
new Object[]{skuId},
new BeanPropertyRowMapper<>(Inventory.class)
);
if (inv.getStock() < qty) {
throw new BizException("库存不足");
}
jdbc.update("UPDATE inventory SET stock = stock - ? WHERE sku_id = ?", qty, skuId);
}六、对比与选型
| 维度 | Redis(Redisson) | ZK | MySQL |
|---|---|---|---|
| 性能 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| 可靠性 | 中 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 公平性 | ❌ | ✅ | ❌ |
| 重入 | ✅ | ✅ | ❌ |
| Watch Dog | ✅ | ❌ | ❌ |
| 实现复杂度 | 低 | 中 | 低 |
| 适用 | 高性能 | 强一致 | 简单场景 |
七、实战:防止重复下单
java
@Service
@RequiredArgsConstructor
public class OrderService {
private final RedissonClient redissonClient;
private final OrderRepository orderRepo;
public void createOrder(CreateOrderRequest req) {
// 防重(用户维度)
String lockKey = "order:create:user:" + req.getUserId();
RLock lock = redissonClient.getLock(lockKey);
try {
// 等 1 秒,持 5 秒
if (!lock.tryLock(1, 5, TimeUnit.SECONDS)) {
throw new BizException("请求过于频繁,请稍后");
}
// 业务幂等:订单号已存在直接返回
if (orderRepo.existsByOrderNo(req.getOrderNo())) {
return;
}
// 下单流程
orderRepo.save(new Order(req));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BizException("下单失败");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}八、Redis 锁的坑
8.1 锁过期 vs 业务慢
场景:锁 5 秒,业务要 7 秒。
java
// 解决 1:长业务拆短,中途续期(WatchDog 自动做)
// 解决 2:业务带锁外核,业务结束再释放
// 解决 3:定时任务系统,慎用长持锁8.2 主从切换
主 Redis 挂,从 Redis 升级,新主无锁信息 → 锁失效。
解决:
- RedLock:N 个独立 Redis,多数加锁成功才算成功
- Redisson RedissonRedLock:类似实现
8.3 集群模式
java
Config config = new Config();
config.useClusterServers()
.addNodeAddress("redis://node1:6379", "redis://node2:6379", "redis://node3:6379")
.setPassword("xxx");
RedissonClient client = Redisson.create(config);8.4 锁泄漏
java
// 业务异常但 finally 内仍要释放
try {
lock.lock();
// ... 业务
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}九、可重入原理
Redis 中存储的 hash:
HSET mylock
<threadId:UUID> <count>每次 unlock 计数 -1,= 0 时真正删除 key。
十、本章小结
| 工具 | 优点 | 适用 |
|---|---|---|
| Redisson | 功能丰富,Watch Dog | 高性能首选 |
| RedLock | 高可用 | 关键路径 |
| ZK | 强一致 | 金融 |
| MySQL | 实现简单 | 已有 DB,简单场景 |
动手练习
- 用 Redisson 实现"用户 5 秒内只能请求 1 次"的限流
- 模拟锁超时,观察 Watch Dog 自动续期
- 写两段并发代码,用分布式锁保证一致性
- 用 Curator 写一个 ZK 互斥锁
推荐阅读
下一章:第 229 章:链路追踪