分布式锁隐患解析:当业务执行时间超过锁过期时间的完整对策

前段时间排查公司智能办公系统异常时,发现一个数据不一致问题:明明对会议室预约加了分布式锁,却依然出现同一时段重复预订!排查后发现,问题的根源在于分布式锁的过期时间 ------ 锁到期自动释放了,但预约审批流程还没执行完,导致多个用户抢到了同一时段的会议室。这个问题在测试环境几乎无法复现,却在每周管理层会议前集中预约时频繁触发。今天把这个坑和解决方案分享出来,希望能帮大家避开这个隐患。

问题复现

先看个真实案例:公司内部会议室预约系统,为防止同一时段多人预订,使用 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 完成审批、创建预约记录时,它不知道锁已过期,仍会继续创建预约,导致同一时段被两人预约。

用时序图说明这个问题:

这导致了同一个会议室在同一时段被两个不同团队预约,造成会议冲突和资源浪费。

问题根源分析

这个问题的本质在于:

  1. 锁的有效期与业务执行时间不匹配:锁设置了固定过期时间,但审批流程等业务执行时间不可预测。
  2. 缺乏锁状态检查:业务执行期间没有检查锁是否还由自己持有。
  3. 释放锁非原子操作:检查和删除锁的两步操作中间可能被其他线程打断。
  4. 业务未实现幂等性 :预约接口没有设计成幂等的,无法通过唯一标识(如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 原生命令的优势:

  1. 内置自动续期机制:无需自己实现看门狗
  2. 可重入锁支持:同一线程可以多次获取同一把锁
  3. 异常处理完善:锁持有者崩溃时,锁会自动过期释放
  4. 多种锁类型支持:读写锁、公平锁、联锁等多种锁实现

需要注意,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 机制保证写操作互斥
  • 适合读多写少场景,写冲突多时性能会因频繁重试而下降

乐观锁工作流程:

graph TD A[获取会议室和版本号] --> B[检查时间段可用性] B --> C[执行业务逻辑] C --> D[尝试创建预约] D --> E{创建成功?} E -- 是 --> F[结束] E -- 否 --> G{达到最大重试次数?} G -- 是 --> H[预约失败] G -- 否 --> A

实战案例:智能办公平台的双保险方案

在我们公司智能办公平台重构中,采用了 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%

调优过程中发现的问题:

  1. 初期锁粒度过粗(整个会议室),改为会议室+时间段的细粒度锁后改善
  2. 审批流程中的外部系统调用(如企业日历同步)偶发超时,增加超时处理后提高稳定性
  3. 数据库查询未使用正确索引,导致高并发下性能下降,优化索引后改善
  4. 预约成功后的消息通知放在了锁内执行,移到锁外后减少了锁持有时间

监控与告警配置

我们设置了关键指标监控,及时发现锁相关问题:

  • 锁获取超时率 > 5%
  • 锁获取平均耗时 > 100ms
  • 预约成功率 < 90%
  • 相同时段重复预约次数 > 0(这是严重异常,立即报警)

解决方案对比

pie title 各方案适用场景占比(参考) "手动看门狗" : 15 "Redisson" : 55 "Fencing Token" : 20 "乐观锁" : 10

数据仅供参考,实际应根据业务需求选择

详细对比:

解决方案 锁类型 实现复杂度 性能影响 锁释放复杂度 优点 缺点 适用场景
手动看门狗 悲观锁 中等 续期线程增加 CPU 开销 高(需 Lua 脚本+线程管理) 完全控制续期逻辑 需自己实现,边界情况多 特殊锁需求场景
Redisson 悲观锁 自动续期,开销小 低(框架自动处理) 开箱即用,功能全 主从切换有一致性风险 大多数分布式锁场景
Fencing Token 悲观+乐观 额外 token 存储与校验 中(需数据库校验) 解决时序问题彻底 需数据库配合实现 一致性要求极高场景
乐观锁 乐观锁 高并发下重试增加 无(依赖数据库事务) 无需额外组件 写冲突多时性能差 读多写少,冲突少场景

锁过期时间设置策略

锁过期时间的合理设置非常关键,可以考虑以下策略:

  1. 统计业务执行时间:收集业务执行时间分布,取 P99(99%)值的 2 倍作为初始租期
  2. 动态调整:根据实时监控的业务执行时间自动调整初始租期
  3. 无限续期:使用看门狗机制,业务执行期间持续续期
  4. 业务分段执行:将长业务拆分成多个短业务,每段单独加锁

我们的经验是:分析业务执行时间分布,取 P95 值的 3 倍作为初始租期,再配合 Redisson 的自动续期机制。

总结

分布式锁过期问题在各类系统中都可能出现,从会议室预约到资源调度,需要综合考虑多种解决方案:

解决方案 核心机制 适用场景 实现要点
手动看门狗 自动延期 通用场景 守护线程定期续期,注意异常处理
Redisson 框架自带看门狗 大多数项目 使用官方框架,配置合理连接池
Fencing Token 递增令牌 数据一致性要求高 全局递增 ID + 数据库令牌校验
乐观锁 数据版本控制 读多写少 版本号字段索引 + 合理重试策略

实际应用中,我们可以结合多种方案,并需要特别注意:

  • 锁的原子性释放(使用 Lua 脚本)
  • 锁过期导致的数据不一致
  • 业务执行异常时的锁释放
  • 业务幂等性设计(通过唯一请求 ID 防止重复操作)
  • 锁粒度优化(避免热点)
相关推荐
xixixin_8 分钟前
【uniapp】uni.setClipboardData 方法失效 bug 解决方案
java·前端·uni-app
工业互联网专业13 分钟前
基于springboot+vue的校园二手物品交易平台
java·vue.js·spring boot·毕业设计·源码·课程设计·校园二手物品交易平台
isfox20 分钟前
一文拆解 Java CAS:从原理到避坑全攻略
java
JPC客栈26 分钟前
LeetCode面试经典 150 题(Java题解)
java·leetcode·面试
HyperAI超神经37 分钟前
【vLLM 学习】Aqlm 示例
java·开发语言·数据库·人工智能·学习·教程·vllm
异常驯兽师37 分钟前
IntelliJ IDEA 项目导入后 Java 文件图标显示为红色小写 j 的解决方法
java·路径配置
纪元A梦40 分钟前
华为OD机试真题——数据分类(2025A卷:100分)Java/python/JavaScript/C++/C语言/GO六种最佳实现
java·javascript·c++·python·华为od·go·华为od机试题
常年游走在bug的边缘1 小时前
基于spring boot 集成 deepseek 流式输出 的vue3使用指南
java·spring boot·后端·ai
廖广杰1 小时前
java虚拟机-为何元空间取代永久代
后端
熙客1 小时前
Java并发:线程池
java