Spring Boot 中使用 Redis 实现分布式锁
1. 概述
当应用在多个实例上运行时,synchronized 或 ReentrantLock 就不再足够了。这些锁只在单个 JVM 内有效。本文将以两种方式指导你构建分布式锁:自己使用 Spring Data Redis 实现,以及使用 Redisson 库。阅读完成后,你将了解如何安全地获取和释放锁、如何为锁编写测试,以及 Redis 锁的安全性限制。
2. 为什么需要分布式锁
假设你有一个 OrderService 运行在 3 个 Pod 上。两个客户同时购买了仓库中最后一个商品。两个请求分别到达两个不同的 Pod。它们都读到 stock = 1,都减少了库存,结果是超卖了。
另一个例子是一个 @Scheduled 的任务在每天早上 2 点生成报告。如果有 3 个实例,该任务就会运行 3 次,客户会收到 3 封完全相同的邮件。
在这两种情况下,你都需要一个所有实例都能看到的锁。Redis 非常适合这个角色,原因有三:
- Redis 在单个线程上依次处理命令,所以每条命令都是原子的。
- Key 可以设置 TTL,所以如果持有锁的进程崩溃了,锁会自动过期。
- 延迟低,而且大多数系统已经有 Redis。
3. 原理:SET NX PX 命令
整个机制基于一条命令:
redis-cli SET lock:product:42 "c1f9-token" NX PX 10000
每个部分的含义:
NX:仅当 key 不存在时才写入。这是“获取锁”的步骤。PX 10000:key 在 10 秒后自动过期。这样,如果进程中途崩溃,锁不会永远被卡住。"c1f9-token":一个随机值,对每次获取锁都是唯一的。这个值用来确定谁持有锁。
连续运行上面的命令两次,你会看到:
OK
(nil)
第一次返回 OK,表示你获得了锁。第二次返回 (nil),因为 key 已经存在。
注意: 不要先用
SETNX,再用单独的EXPIRE命令。如果进程在两条命令之间崩溃,key 将永远不会过期,你会陷入死锁。
4. 使用 Spring Data Redis 自己实现
4.1. 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
在 application.properties 中配置连接:
spring.data.redis.host=localhost
spring.data.redis.port=6379
4.2. 获取和释放锁
@Component
public class RedisLock {
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = new DefaultRedisScript<>("""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""", Long.class);
private final StringRedisTemplate redis;
public RedisLock(StringRedisTemplate redis) {
this.redis = redis;
}
public Optional<String> tryLock(String key, Duration ttl) {
String token = UUID.randomUUID().toString();
Boolean acquired = redis.opsForValue().setIfAbsent(key, token, ttl);
return Boolean.TRUE.equals(acquired) ? Optional.of(token) : Optional.empty();
}
public boolean unlock(String key, String token) {
Long deleted = redis.execute(RELEASE_SCRIPT, List.of(key), token);
return deleted != null && deleted == 1L;
}
}
解释:
setIfAbsent(key, token, ttl)生成正确的SET key token NX PX ttl命令。返回值true表示你已经获得锁。tryLock在成功时返回 token。你必须保存这个 token,之后才能释放锁。unlock运行一个 Lua 脚本。脚本检查 key 的值是否确实是你的 token,并且只在匹配时删除 key。
为什么不直接调用 DEL?考虑这种情况。Instance A 用 TTL 10 秒获取锁,但处理花费 12 秒。在第 10 秒,锁过期,instance B 获得锁。在第 12 秒,A 调用 DEL 并删除了 B 的锁。现在 instance C 可以进入,B 和 C 并行运行。
如果你分开调用 GET 然后 DEL,两条命令之间还是有间隙。Lua 脚本由 Redis 原子性执行,所以没有这样的间隙。
4.3. 在 service 中使用
public record Order(long productId, int quantity) {}
@Service
public class OrderService {
private final RedisLock redisLock;
private final InventoryRepository inventory;
public OrderService(RedisLock redisLock, InventoryRepository inventory) {
this.redisLock = redisLock;
this.inventory = inventory;
}
public void placeOrder(Order order) {
String key = "lock:product:" + order.productId();
String token = redisLock.tryLock(key, Duration.ofSeconds(10))
.orElseThrow(() -> new IllegalStateException("商品正在处理中,请稍后重试"));
try {
inventory.decrease(order.productId(), order.quantity());
} finally {
redisLock.unlock(key, token);
}
}
}
有三点需要注意:
- 锁的 key 应该针对具体资源,这里是
lock:product:{id}。如果对所有商品使用同一把锁,所有订单将被序列化处理。 unlock总是在finally块中,确保即使发生异常也会释放锁。- 这个例子在无法获取锁时快速失败。如果要等待,你可以重试几次,使用短的退避。不应该使用无限制的
while(true)循环。
InventoryRepository 是你项目中的任何仓库,比如 Spring Data JPA 仓库。
5. 使用 Redisson
自己实现可以帮助你理解机制,但还缺少几个功能:当处理花费很长时间时自动延长锁的期限、可重入性,以及使用 pub/sub 而不是轮询来等待锁。Redisson 提供了这些功能。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.45.1</version>
</dependency>
你应该检查与项目中 Spring Boot 版本兼容的最新版本。这个 starter 会读取 spring.data.redis.* 配置并自动创建 RedissonClient bean。
@Service
public class DailyReportJob {
private static final Logger log = LoggerFactory.getLogger(DailyReportJob.class);
private final RedissonClient redisson;
public DailyReportJob(RedissonClient redisson) {
this.redisson = redisson;
}
@Scheduled(cron = "0 0 2 * * *")
public void generate() throws InterruptedException {
RLock lock = redisson.getLock("lock:daily-report");
if (!lock.tryLock(0, TimeUnit.SECONDS)) {
log.info("另一个实例正在运行报告,跳过");
return;
}
try {
log.info("开始生成每日报告");
// 查询数据、导出文件、发送邮件...
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
当 job 在 3 个实例上运行时,日志会像这样:
[pod-1] 开始生成每日报告
[pod-2] 另一个实例正在运行报告,跳过
[pod-3] 另一个实例正在运行报告,跳过
重要的几点:
tryLock(0, TimeUnit.SECONDS)不等待。如果锁被持有,该方法立即返回false。- 当你不指定
leaseTime时,Redisson 启用 watchdog。锁的 TTL 默认为 30 秒(lockWatchdogTimeout),并且大约每 10 秒续一次,只要线程仍然持有锁。如果 JVM 死亡,续期停止,锁自动过期。 - 当你用具体的
leaseTime调用tryLock(wait, lease, unit)时,watchdog 被禁用。锁会在lease时间后过期,不管工作是否完成。 RLock与线程关联,是可重入的。如果从不同的线程调用unlock(),你会得到IllegalMonitorStateException。与CompletableFuture或响应式代码一起使用时需要特别小心。
注意: 因为
RLock与线程关联,使用虚拟线程和那些在中途切换线程的框架时需要小心。锁必须在同一线程上获取和释放。
6. 使用 Testcontainers 编写测试
你应该在真实 Redis 上测试锁,不要使用 mock。添加以下测试依赖(版本由 Spring Boot 管理):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@DataRedisTest
@Import(RedisLock.class)
@Testcontainers
class RedisLockTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired
RedisLock redisLock;
@Autowired
StringRedisTemplate template;
@Test
void givenConcurrentRequests_whenTryLock_thenOnlyOneSucceeds() throws Exception {
List<Callable<Optional<String>>> tasks = IntStream.range(0, 20)
.<Callable<Optional<String>>>mapToObj(i -> () -> redisLock.tryLock("lock:concurrent", Duration.ofSeconds(5)))
.toList();
try (ExecutorService executor = Executors.newFixedThreadPool(20)) {
long acquired = executor.invokeAll(tasks).stream()
.map(Future::resultNow)
.filter(Optional::isPresent)
.count();
assertThat(acquired).isEqualTo(1);
}
}
@Test
void givenWrongToken_whenUnlock_thenLockIsKept() {
String token = redisLock.tryLock("lock:owner", Duration.ofSeconds(5)).orElseThrow();
assertThat(redisLock.unlock("lock:owner", "not-my-token")).isFalse();
assertThat(template.hasKey("lock:owner")).isTrue();
assertThat(redisLock.unlock("lock:owner", token)).isTrue();
assertThat(template.hasKey("lock:owner")).isFalse();
}
}
解释:
@DataRedisTest仅加载 Redis 配置部分,所以测试运行很快。@Import(RedisLock.class)将被测试的 bean 引入上下文。@ServiceConnection(name = "redis")自动将StringRedisTemplate指向刚启动的容器。你不需要手动配置主机和端口。- 第一个测试让 20 个线程竞争一把锁,验证只有 1 个线程获胜。
- 第二个测试验证 Lua 脚本工作正确:错误的 token 无法删除锁,正确的 token 可以。
7. 常见错误和限制
7.1. TTL 短于处理时间
这是最常见的错误。锁在工作仍在进行时过期,另一个实例会进入。问题不仅是代码慢。GC 暂停、网络延迟,或等待连接池也可能导致你超过 TTL。
有两种处理方法。第一种是使用 Redisson 的 watchdog。第二种是设置充足的 TTL,比如 p99 处理时间的几倍。
7.2. 锁不提供绝对保证
Redis 复制是异步的。假设 master 接收到锁但在复制到 replica 前崩溃了。Replica 被提升为新 master,上面没有这个 key。此时另一个客户端可以获得相同的锁。
Redlock 算法(在 N 个独立节点中获取多数派的锁)降低了这种风险。然而,Redlock 仍然有争议,因为它依赖于时钟和延迟的假设。在 Redisson 中,RedissonRedLock 现已被标记为过时。
如果数据错误造成严重后果,比如重复扣款,不要仅依赖锁。结合使用 fencing token:每次获取锁时,你会收到一个递增的数字(比如来自 INCR lock:product:42:fence)。存储层会拒绝携带旧 token 的写入:
UPDATE inventory
SET stock = stock - :qty, last_fence = :fence
WHERE product_id = :id AND last_fence < :fence;
如果 UPDATE 影响 0 行,说明一个带更新 token 的进程已经先写入了。旧的请求会被拒绝。
7.3. 其他错误
- 对所有资源使用同一个 key,导致吞吐量严重下降。
- 忘记
finally,所以锁只在 TTL 过期时才释放。 - 当不再持有锁时调用 Redisson 的
unlock()(因为已过期)。在调用前检查isHeldByCurrentThread()。 - 连续重试获取锁,没有退避,导致 Redis 被请求淹没。
7.4. 何时不应使用
- 应用只有一个实例:
ReentrantLock足够且简单得多。 - 数据在关系数据库中:乐观锁(
@Version)、SELECT ... FOR UPDATE,或 unique 约束通常更安全。它们与数据在同一事务中。 - 需要绝对保证:考虑使用具有共识的系统,如 ZooKeeper 或 etcd,结合 fencing token。
- 只需要消息恰好处理一次:幂等性 key 或消息 broker 的 consumer group 通常比锁更合适。
8. 总结
Redis 中的分布式锁基于三个原则:使用原子 SET NX PX 命令获取锁、为每次获取锁绑定唯一 token,以及使用带 token 检查的 Lua 脚本释放锁。你可以用 StringRedisTemplate 自己实现(只需几十行代码),或使用 Redisson 以获得 watchdog、可重入性和高效的等待机制。
记住,Redis 锁在大多数情况下有助于避免重复处理,但不是绝对保证。对于关键操作,结合锁和 fencing token 或数据库约束。此外,使用 Testcontainers 在真实 Redis 上测试你的锁。