前段时间排查公司智能办公系统异常时,发现一个数据不一致问题:明明对会议室预约加了分布式锁,却依然出现同一时段重复预订!排查后发现,问题的根源在于分布式锁的过期时间 ------ 锁到期自动释放了,但预约审批流程还没执行完,导致多个用户抢到了同一时段的会议室。这个问题在测试环境几乎无法复现,却在每周管理层会议前集中预约时频繁触发。今天把这个坑和解决方案分享出来,希望能帮大家避开这个隐患。
问题复现
先看个真实案例:公司内部会议室预约系统,为防止同一时段多人预订,使用 Redis 实现了分布式锁:
java
public boolean bookMeetingRoom(String roomId, Date startTime, Date endTime, String userId) {
// 生成锁的key,基于会议室和时间段
String lockKey = "meeting_lock:" + roomId + ":" + startTime.getTime();
String requestId = UUID.randomUUID().toString();
try {
// 获取锁,设置10秒过期时间
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);
if (!locked) {
return false;
}
// 执行预约业务逻辑 - 检查会议室是否可用
boolean isAvailable = meetingRoomService.checkAvailability(roomId, startTime, endTime);
if (!isAvailable) {
return false;
}
// 模拟审批流程和通知相关人员等耗时操作
processComplexBookingFlow(roomId, userId);
// 创建预约记录
MeetingBooking booking = new MeetingBooking();
booking.setRoomId(roomId);
booking.setStartTime(startTime);
booking.setEndTime(endTime);
booking.setUserId(userId);
meetingBookingService.createBooking(booking);
return true;
} finally {
// 释放锁
if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}
}
}
private void processComplexBookingFlow(String roomId, String userId) {
// 模拟复杂业务逻辑:审批流程、日历同步、邮件通知等
try {
Thread.sleep(15000); // 15秒,超过锁的10秒过期时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
问题出在哪里?用户 A 申请预约会议室,获取锁后开始执行预约逻辑,包括审批流程等,这些操作耗时 15 秒。但锁只设置了 10 秒过期时间,10 秒后锁自动释放,用户 B 获取锁并开始预约流程。当用户 A 完成审批、创建预约记录时,它不知道锁已过期,仍会继续创建预约,导致同一时段被两人预约。
用时序图说明这个问题:

这导致了同一个会议室在同一时段被两个不同团队预约,造成会议冲突和资源浪费。
问题根源分析
这个问题的本质在于:
- 锁的有效期与业务执行时间不匹配:锁设置了固定过期时间,但审批流程等业务执行时间不可预测。
- 缺乏锁状态检查:业务执行期间没有检查锁是否还由自己持有。
- 释放锁非原子操作:检查和删除锁的两步操作中间可能被其他线程打断。
- 业务未实现幂等性 :预约接口没有设计成幂等的,无法通过唯一标识(如
userId+roomId+startTime
)防止重复预约。
容易踩坑的场景:
- 需要人工审批的流程
- 涉及多系统交互(如日历系统、考勤系统)
- 发送邮件或消息通知耗时
- 复杂权限校验逻辑
- 资源预分配计算耗时长
解决方案详解
1. 自己实现看门狗机制
通过后台线程定期检查锁状态并自动续期:
java
public boolean bookMeetingRoomWithWatchDog(String roomId, Date startTime, Date endTime, String userId) {
String lockKey = "meeting_lock:" + roomId + ":" + startTime.getTime();
String requestId = UUID.randomUUID().toString();
// 用于自动续期的线程池
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
AtomicBoolean watchdogActive = new AtomicBoolean(true);
AtomicBoolean businessShouldStop = new AtomicBoolean(false);
try {
// 获取锁,设置30秒过期时间
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
if (!locked) {
return false;
}
// 启动看门狗,每10秒检查一次并延长锁过期时间
scheduler.scheduleAtFixedRate(() -> {
try {
if (watchdogActive.get()) {
String currentLockValue = redisTemplate.opsForValue().get(lockKey);
if (requestId.equals(currentLockValue)) {
redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS);
log.info("锁续期成功:{}", lockKey);
} else {
// 锁已被其他线程获取或被管理员手动删除
log.warn("锁已丢失,可能被强制删除或被其他用户获取");
businessShouldStop.set(true);
scheduler.shutdown();
}
} else {
scheduler.shutdown();
}
} catch (Exception e) {
log.error("锁续期异常: {}", e.getMessage(), e);
}
}, 10, 10, TimeUnit.SECONDS);
// 执行业务逻辑
boolean isAvailable = meetingRoomService.checkAvailability(roomId, startTime, endTime);
if (!isAvailable) {
return false;
}
// 执行复杂业务流程
processComplexBookingFlow(roomId, userId);
// 检查业务执行期间锁是否被破坏
if (businessShouldStop.get()) {
log.error("锁已丢失,终止当前预约操作防止冲突");
return false;
}
// 创建预约记录
MeetingBooking booking = new MeetingBooking();
booking.setRoomId(roomId);
booking.setStartTime(startTime);
booking.setEndTime(endTime);
booking.setUserId(userId);
meetingBookingService.createBooking(booking);
return true;
} finally {
// 停止看门狗
watchdogActive.set(false);
// 优雅关闭线程池
scheduler.shutdown();
try {
scheduler.awaitTermination(100, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 使用Lua脚本原子性释放锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockKey), requestId);
}
}
看门狗工作流程:

2. 使用 Redisson 框架的自动续期
Redisson 提供了成熟的分布式锁实现,内置看门狗机制:
java
public boolean bookMeetingRoomWithRedisson(String roomId, Date startTime, Date endTime, String userId) {
String lockKey = "meeting_lock:" + roomId + ":" + startTime.getTime();
// 注意:实际项目中RedissonClient应通过Spring注入或单例模式管理
// 这里为了示例简化直接创建
RedissonClient redisson = Redisson.create();
RLock lock = redisson.getLock(lockKey);
try {
// 参数解析:
// 1. 最大等待时间100秒 - 等待获取锁的超时时间
// 2. 初始租期30秒 - 锁的初始有效期
// 3. 时间单位
boolean isLocked = lock.tryLock(100, 30, TimeUnit.SECONDS);
if (!isLocked) {
return false;
}
// 执行业务逻辑
boolean isAvailable = meetingRoomService.checkAvailability(roomId, startTime, endTime);
if (!isAvailable) {
return false;
}
// Redisson自动续期机制:
// 当剩余租期小于初始租期的1/3时(此例中为10秒),
// 自动将租期重置为初始设定的30秒
processComplexBookingFlow(roomId, userId);
// 创建预约记录
MeetingBooking booking = new MeetingBooking();
booking.setRoomId(roomId);
booking.setStartTime(startTime);
booking.setEndTime(endTime);
booking.setUserId(userId);
meetingBookingService.createBooking(booking);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
// 释放锁 - Redisson会自动判断锁是否由当前线程持有
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
Redisson 相比自己实现或使用 Redis 原生命令的优势:
- 内置自动续期机制:无需自己实现看门狗
- 可重入锁支持:同一线程可以多次获取同一把锁
- 异常处理完善:锁持有者崩溃时,锁会自动过期释放
- 多种锁类型支持:读写锁、公平锁、联锁等多种锁实现
需要注意,Redisson 基于 Redis 的分布式锁具有 AP 特性(可用性优先)。在 Redis 主从异步复制场景下,若主节点在锁创建后、同步到从节点前宕机,新主节点可能未存储该锁,导致短暂的锁失效(概率约为1 - 主从同步成功率
)。对数据一致性要求极高的场景可考虑使用 Redisson 的 RedLock 红锁方案或 ZooKeeper 实现分布式锁。
3. Fencing Token(栅栏令牌)方案
Fencing Token 通过全局递增令牌解决锁失效问题,即使锁过期,也能通过令牌顺序保证操作的正确性:
java
public boolean bookMeetingRoomWithFencingToken(String roomId, Date startTime, Date endTime, String userId) {
String lockKey = "meeting_lock:" + roomId + ":" + startTime.getTime();
String tokenKey = "token:" + roomId + ":" + startTime.getTime();
// 获取并递增token - Redis的INCR操作保证全局唯一递增
Long token = redisTemplate.opsForValue().increment(tokenKey);
String requestId = token.toString();
try {
// 获取锁
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
if (!locked) {
return false;
}
// 执行业务逻辑
boolean isAvailable = meetingRoomService.checkAvailability(roomId, startTime, endTime);
if (!isAvailable) {
return false;
}
// 模拟复杂业务
processComplexBookingFlow(roomId, userId);
// 关键点:即使锁已过期,这里也能通过token确保数据正确性
// 即使客户端崩溃,未释放锁,后续客户端也能通过获取更大token并等待锁过期来执行操作
boolean booked = meetingBookingService.createBookingWithToken(roomId, startTime, endTime, userId, token);
// 如果token不是最新的,预约会失败
return booked;
} finally {
// 原子性释放锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockKey), requestId);
}
}
数据库层的 token 验证:
java
public boolean createBookingWithToken(String roomId, Date startTime, Date endTime, String userId, Long token) {
// 方案1:使用两步操作(性能更好)
// 1. 先检查是否有冲突预约且token更大
boolean hasConflict = jdbcTemplate.queryForObject(
"SELECT EXISTS(SELECT 1 FROM meeting_bookings WHERE room_id = ? AND " +
"((start_time <= ? AND end_time > ?) OR (start_time < ? AND end_time >= ?)) " +
"AND last_token >= ?)",
Boolean.class, roomId, endTime, startTime, startTime, endTime, token);
if (hasConflict) {
return false;
}
// 2. 无冲突则创建预约
jdbcTemplate.update(
"INSERT INTO meeting_bookings (room_id, start_time, end_time, user_id, last_token) " +
"VALUES (?, ?, ?, ?, ?)",
roomId, startTime, endTime, userId, token);
return true;
// 方案2:使用唯一索引和ON DUPLICATE KEY UPDATE(原子性更好)
// 需要先在表上创建唯一索引:ALTER TABLE meeting_bookings ADD UNIQUE INDEX
// idx_room_time (room_id, start_time, end_time);
/*
int inserted = jdbcTemplate.update(
"INSERT INTO meeting_bookings (room_id, start_time, end_time, user_id, last_token) " +
"VALUES (?, ?, ?, ?, ?) " +
"ON DUPLICATE KEY UPDATE user_id = IF(last_token < VALUES(last_token), VALUES(user_id), user_id), " +
"last_token = GREATEST(last_token, VALUES(last_token))",
roomId, startTime, endTime, userId, token);
// 判断是否是当前用户的预约成功 - 需要额外查询验证
if (inserted > 0) {
return true;
}
String currentUserId = jdbcTemplate.queryForObject(
"SELECT user_id FROM meeting_bookings WHERE room_id = ? AND start_time = ? AND end_time = ?",
String.class, roomId, startTime, endTime);
return userId.equals(currentUserId);
*/
}
Fencing Token 的核心优势:即使客户端获取锁后崩溃或锁异常释放,也能通过令牌顺序确保操作按正确顺序执行,从根本上解决了"锁已过期但业务仍在执行"导致的数据不一致问题。

4. 数据版本号的乐观锁方案
乐观锁通过数据版本控制,完全不依赖分布式锁的有效期:
java
public boolean bookMeetingRoomWithOptimisticLock(String roomId, Date startTime, Date endTime, String userId) {
int maxRetries = 5;
int retries = 0;
while (retries < maxRetries) {
// 获取会议室信息和版本号
MeetingRoom room = meetingRoomService.getRoomWithVersion(roomId);
// 检查该时间段是否已被预约
boolean isAvailable = meetingRoomService.checkAvailability(roomId, startTime, endTime);
if (!isAvailable) {
return false;
}
// 执行业务逻辑
processComplexBookingFlow(roomId, userId);
// 使用版本号创建预约
boolean booked = meetingBookingService.createBookingWithVersion(
roomId, startTime, endTime, userId, room.getVersion());
if (booked) {
return true;
}
// 更新失败,说明数据已被其他线程修改,重试
retries++;
log.info("乐观锁冲突,重试第{}次", retries);
}
return false;
}
数据库更新方法:
java
public boolean createBookingWithVersion(String roomId, Date startTime, Date endTime, String userId, int version) {
// 先更新会议室版本号,确保原子性
int roomUpdated = jdbcTemplate.update(
"UPDATE meeting_rooms SET version = version + 1 WHERE id = ? AND version = ?",
roomId, version);
if (roomUpdated <= 0) {
return false;
}
// 然后创建预约记录
jdbcTemplate.update(
"INSERT INTO meeting_bookings (room_id, start_time, end_time, user_id) VALUES (?, ?, ?, ?)",
roomId, startTime, endTime, userId);
return true;
}
乐观锁的特点:
- 通过数据库条件更新(
WHERE version = ?
)实现应用层的无锁化并发控制 - 底层依赖数据库的行级锁机制(如 InnoDB 的
SELECT ... FOR UPDATE
)或 MVCC 机制保证写操作互斥 - 适合读多写少场景,写冲突多时性能会因频繁重试而下降
乐观锁工作流程:
实战案例:智能办公平台的双保险方案
在我们公司智能办公平台重构中,采用了 Redisson+乐观锁的组合方案。下面是具体实现和优化经验:
Redisson 的 Spring Boot 集成与配置优化
java
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379")
// 连接池参数根据并发量调整
.setConnectionMinimumIdleSize(5) // 空闲连接数,避免突发流量时创建连接延迟
.setConnectionPoolSize(20) // 最大连接数,根据系统并发量设置,避免连接不足
.setIdleConnectionTimeout(30000); // 空闲连接超时时间,减少资源占用
return Redisson.create(config);
}
}
分布式锁+乐观锁的双重保险与幂等性实现
java
@Service
public class MeetingBookingServiceImpl implements MeetingBookingService {
@Autowired
private RedissonClient redisson;
@Override
@Transactional
public boolean bookMeetingRoom(String roomId, Date startTime, Date endTime, String userId) {
// 生成幂等性请求ID,防止重复提交
String requestId = userId + ":" + roomId + ":" + startTime.getTime();
String idempotentKey = "idempotent:meeting:" + requestId;
// 检查是否已处理过该请求
Boolean alreadyProcessed = redisTemplate.opsForValue().setIfAbsent(idempotentKey, "1", 24, TimeUnit.HOURS);
if (alreadyProcessed == null || !alreadyProcessed) {
log.info("重复的预约请求已被拦截: {}", requestId);
return false;
}
String lockKey = "meeting_lock:" + roomId + ":" + startTime.getTime();
RLock lock = redisson.getLock(lockKey);
try {
// 尝试获取锁,最多等待3秒,锁有效期30秒(自动续期)
if (!lock.tryLock(3, 30, TimeUnit.SECONDS)) {
log.warn("获取锁失败,会议室可能正在被预约: {}", roomId);
return false;
}
// 使用乐观锁创建预约
int retries = 0;
while (retries < 3) {
MeetingRoom room = meetingRoomRepository.findByIdWithVersion(roomId);
boolean isAvailable = checkTimeSlotAvailability(roomId, startTime, endTime);
if (!isAvailable) {
log.info("会议室在该时段已被预约: {}, {}-{}", roomId, startTime, endTime);
return false;
}
// 执行预约审批流程
processBookingApproval(roomId, userId, startTime, endTime);
boolean created = createBookingWithVersion(roomId, startTime, endTime, userId, room.getVersion());
if (created) {
// 发送预约成功通知
notificationService.sendBookingConfirmation(roomId, userId, startTime, endTime);
return true;
}
retries++;
log.debug("乐观锁冲突,重试第{}/3次", retries);
}
log.warn("创建预约失败,乐观锁重试次数用尽");
return false;
} catch (Exception e) {
log.error("预约会议室异常", e);
throw new RuntimeException("预约会议室失败", e);
} finally {
// 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
// 其他方法实现...
}
锁粒度优化与热门会议室处理
我们发现在每天上午 9-10 点,大会议室预约竞争特别激烈,通过调整锁粒度解决了这个问题:
java
// 改进前:会议室+时间的锁
String lockKey = "meeting_lock:" + roomId + ":" + startTime.getTime();
// 改进后:按会议室容量分类的细粒度锁
// 大会议室(容量>20)竞争更激烈,使用更细粒度的时间段锁
if (room.getCapacity() > 20) {
// 将一小时分成4个15分钟的时间段
int timeSlot = (startTime.getMinutes() / 15);
lockKey = "meeting_lock:" + roomId + ":" + startTime.getTime() + ":slot:" + timeSlot;
} else {
// 小会议室竞争不激烈,使用小时级锁即可
lockKey = "meeting_lock:" + roomId + ":" + startTime.getTime();
}
这种分段锁策略在高峰期会议室预约场景下,并发性能提升了 2.5 倍,有效缓解了热门时段的锁竞争问题。
压测关键指标和经验
在每周一早上的会议室预约高峰期(150-200 并发预约请求)中的实际表现:
- 锁获取平均耗时:8ms
- 锁获取失败率:4.5%(表示 4.5%的请求因锁竞争失败,符合高并发场景下的流量控制预期,避免过度竞争导致系统负载过高)
- 审批流程平均执行时间:1.2 秒
- 数据库乐观锁冲突率:0.8%
调优过程中发现的问题:
- 初期锁粒度过粗(整个会议室),改为会议室+时间段的细粒度锁后改善
- 审批流程中的外部系统调用(如企业日历同步)偶发超时,增加超时处理后提高稳定性
- 数据库查询未使用正确索引,导致高并发下性能下降,优化索引后改善
- 预约成功后的消息通知放在了锁内执行,移到锁外后减少了锁持有时间
监控与告警配置
我们设置了关键指标监控,及时发现锁相关问题:
- 锁获取超时率 > 5%
- 锁获取平均耗时 > 100ms
- 预约成功率 < 90%
- 相同时段重复预约次数 > 0(这是严重异常,立即报警)
解决方案对比
数据仅供参考,实际应根据业务需求选择
详细对比:
解决方案 | 锁类型 | 实现复杂度 | 性能影响 | 锁释放复杂度 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|---|---|---|
手动看门狗 | 悲观锁 | 中等 | 续期线程增加 CPU 开销 | 高(需 Lua 脚本+线程管理) | 完全控制续期逻辑 | 需自己实现,边界情况多 | 特殊锁需求场景 |
Redisson | 悲观锁 | 低 | 自动续期,开销小 | 低(框架自动处理) | 开箱即用,功能全 | 主从切换有一致性风险 | 大多数分布式锁场景 |
Fencing Token | 悲观+乐观 | 高 | 额外 token 存储与校验 | 中(需数据库校验) | 解决时序问题彻底 | 需数据库配合实现 | 一致性要求极高场景 |
乐观锁 | 乐观锁 | 低 | 高并发下重试增加 | 无(依赖数据库事务) | 无需额外组件 | 写冲突多时性能差 | 读多写少,冲突少场景 |
锁过期时间设置策略
锁过期时间的合理设置非常关键,可以考虑以下策略:
- 统计业务执行时间:收集业务执行时间分布,取 P99(99%)值的 2 倍作为初始租期
- 动态调整:根据实时监控的业务执行时间自动调整初始租期
- 无限续期:使用看门狗机制,业务执行期间持续续期
- 业务分段执行:将长业务拆分成多个短业务,每段单独加锁
我们的经验是:分析业务执行时间分布,取 P95 值的 3 倍作为初始租期,再配合 Redisson 的自动续期机制。
总结
分布式锁过期问题在各类系统中都可能出现,从会议室预约到资源调度,需要综合考虑多种解决方案:
解决方案 | 核心机制 | 适用场景 | 实现要点 |
---|---|---|---|
手动看门狗 | 自动延期 | 通用场景 | 守护线程定期续期,注意异常处理 |
Redisson | 框架自带看门狗 | 大多数项目 | 使用官方框架,配置合理连接池 |
Fencing Token | 递增令牌 | 数据一致性要求高 | 全局递增 ID + 数据库令牌校验 |
乐观锁 | 数据版本控制 | 读多写少 | 版本号字段索引 + 合理重试策略 |
实际应用中,我们可以结合多种方案,并需要特别注意:
- 锁的原子性释放(使用 Lua 脚本)
- 锁过期导致的数据不一致
- 业务执行异常时的锁释放
- 业务幂等性设计(通过唯一请求 ID 防止重复操作)
- 锁粒度优化(避免热点)