想象一下,你正盯着电脑屏幕,下午两点有个重要的客户会议,你已经在 OA 系统里把三楼最大的会议室“A”订好了。就在你准备提交确认的那一刻,你的同事老王也正好需要这个会议室开同样的会。两人几乎同时点击了“确认”,系统提示“会议室已被占用”,订单失败。
这不仅仅是倒霉,这是典型的并发冲突。在软件开发的世界里,这种场景每天都在发生,而且比订会议室复杂得多——数据库里的每一行数据、每一个库存扣减、每一笔转账,都可能面临同样的“抢会议室”危机。今天,我们就来聊聊如何用最经典的悲观锁(Pessimistic Locking)结合超时重试机制,优雅地解决这场混乱。
一、 为什么“抢会议室”会引发死锁?
首先,我们需要理解问题的根源。在数据库领域,当两个或多个事务同时请求同一资源,却又互相等待对方释放锁时,就会形成“死锁(Deadlock)”。
举个极端但真实的例子:
- 事务 A 想要从会议室 A 转移到会议室 B。
- 事务 B 想要从会议室 B 转移到会议室 A。
- 如果 A 先锁住了 A,B 先锁住了 B,那么 A 在等 B 释放 B,B 在等 A 释放 A。
- 结果:两个事务都卡住了,系统进入僵局,直到某个超时机制强制杀死其中一个。
在生产环境中,死锁会导致事务回滚、用户体验糟糕,甚至引发数据库性能雪崩。悲观锁的核心思想是:“先加锁,后操作”。它假设冲突是最常发生的情况,所以在操作数据之前,先独占资源,确保同一时刻只有一个事务能修改它。
二、 悲观锁的原理与MySQL实现
悲观锁在MySQL中主要通过 SELECT ... FOR UPDATE 来实现。它会在查询到的数据行上加一个排他锁(X锁),其他事务如果想修改或锁定这些数据行,必须等待当前事务提交或回滚后才能进行。
1. 基础场景:会议室预订表
假设我们有一张会议室预订表 meeting_room_bookings:
CREATE TABLE meeting_room_bookings (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
room_id VARCHAR(50) NOT NULL,
user_id VARCHAR(50) NOT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
status TINYINT DEFAULT 1 COMMENT '1: 已预订, 0: 已取消',
version INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_room_time (room_id, start_time, end_time)
);
2. 使用悲观锁防止超卖
当用户尝试预订会议室时,我们需要确保:
- 会议室在该时间段内确实空闲。
- 没有其他事务正在处理同一会议室的同一时间段。
使用悲观锁的SQL如下:
START TRANSACTION;
-- 第一步:查询并锁定目标时间段的所有预订记录
SELECT * FROM meeting_room_bookings
WHERE room_id = 'room-101'
AND status = 1
AND (start_time < '2023-10-25 14:00:00' AND end_time > '2023-10-25 13:00:00')
FOR UPDATE;
-- 如果上面的查询返回0行,说明没有冲突,可以插入
-- 如果返回>0行,说明有冲突,业务逻辑需要处理(如返回错误给前端)
-- 第二步:插入新预订
INSERT INTO meeting_room_bookings (room_id, user_id, start_time, end_time, status)
VALUES ('room-101', 'user-wang', '2023-10-25 13:00:00', '2023-10-25 14:00:00', 1);
COMMIT;
这里的关键是 FOR UPDATE。它会在查询结果集上加排他锁,直到事务结束。其他事务如果尝试对 room-101 的同一时间段进行类似操作,会被阻塞,直到当前事务提交或回滚。
三、 悲观锁的陷阱:超时与死锁
虽然悲观锁解决了并发问题,但它引入了新的挑战:
- 锁等待超时:如果一个事务长时间持有锁,其他事务会无限等待,导致系统响应变慢甚至超时。
- 死锁风险:如前所述,如果多个事务以不同的顺序获取锁,可能形成死锁。
- 性能瓶颈:高并发下,大量事务排队等待锁,吞吐量急剧下降。
因此,我们需要引入超时重试机制来处理这些异常情况。
四、 实战:悲观锁 + 超时重试的Java实现
下面是一个完整的Java示例,展示了如何使用悲观锁和超时重试来安全地预订会议室。
1. 依赖准备
首先,确保你的项目中引入了JDBC和Spring JDBC(或MyBatis)的相关依赖。
2. 配置数据源和事务管理器
@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/meeting_room_db?useSSL=false&serverTimezone=UTC");
dataSource.setUsername("root");
dataSource.setPassword("your_password");
return dataSource;
}
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}
3. 定义会议室预订服务
@Service
public class MeetingRoomBookingService {
private static final Logger logger = LoggerFactory.getLogger(MeetingRoomBookingService.class);
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private PlatformTransactionManager transactionManager;
private static final int MAX_RETRY_COUNT = 3;
private static final long LOCK_WAIT_TIMEOUT_MS = 3000; // 锁等待超时3秒
/**
* 预订会议室,使用悲观锁+超时重试
*/
public boolean bookRoom(String roomId, String userId, LocalDateTime startTime, LocalDateTime endTime) {
int retryCount = 0;
while (retryCount < MAX_RETRY_COUNT) {
retryCount++;
try {
return executeBookingWithPessimisticLock(roomId, userId, startTime, endTime);
} catch (DeadlockLoserDataAccessException e) {
logger.warn("Deadlock detected for room {} retry {}/{}", roomId, retryCount, MAX_RETRY_COUNT);
if (retryCount >= MAX_RETRY_COUNT) {
throw new RuntimeException("Booking failed after multiple retries due to deadlock", e);
}
// 短暂等待后重试
sleep(100);
} catch (CannotAcquireLockException e) {
logger.warn("Lock acquisition timeout for room {} retry {}/{}", roomId, retryCount, MAX_RETRY_COUNT);
if (retryCount >= MAX_RETRY_COUNT) {
throw new RuntimeException("Booking failed due to lock timeout", e);
}
sleep(100);
} catch (Exception e) {
logger.error("Unexpected error during booking", e);
throw e;
}
}
return false;
}
private boolean executeBookingWithPessimisticLock(String roomId, String userId, LocalDateTime startTime, LocalDateTime endTime) {
// 使用事务管理
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
transactionTemplate.setTimeout(10); // 事务超时10秒
return transactionTemplate.execute(status -> {
// 构建查询条件,使用FOR UPDATE锁定
String sql = "SELECT * FROM meeting_room_bookings " +
"WHERE room_id = ? AND status = 1 " +
"AND start_time < ? AND end_time > ? " +
"FOR UPDATE";
try {
// 设置锁等待超时
jdbcTemplate.execute("SET innodb_lock_wait_timeout = " + (LOCK_WAIT_TIMEOUT_MS / 1000));
// 执行查询,如果存在冲突记录,会返回非空结果集
List<MeetingRoomBooking> conflicts = jdbcTemplate.query(sql,
(rs, rowNum) -> new MeetingRoomBooking(
rs.getLong("id"),
rs.getString("room_id"),
rs.getString("user_id"),
rs.getTimestamp("start_time").toLocalDateTime(),
rs.getTimestamp("end_time").toLocalDateTime(),
rs.getInt("status")
),
roomId,
endTime,
startTime
);
if (!conflicts.isEmpty()) {
// 发现冲突,返回false,业务层可以决定是重试还是报错
return false;
}
// 无冲突,插入新预订
String insertSql = "INSERT INTO meeting_room_bookings (room_id, user_id, start_time, end_time, status) VALUES (?, ?, ?, ?, 1)";
int rows = jdbcTemplate.update(insertSql, roomId, userId,
Timestamp.valueOf(startTime),
Timestamp.valueOf(endTime));
if (rows == 1) {
logger.info("Successfully booked room {} for user {}", roomId, userId);
return true;
} else {
throw new RuntimeException("Failed to insert booking");
}
} catch (LockAcquisitionException e) {
// 锁获取失败,抛出特定异常让外层处理重试
throw new CannotAcquireLockException("Failed to acquire lock", e);
} catch (DeadlockLoserDataAccessException e) {
// 死锁发生,抛出特定异常让外层处理重试
throw e;
}
});
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted during retry sleep", e);
}
}
// 内部数据类
public static class MeetingRoomBooking {
private long id;
private String roomId;
private String userId;
private LocalDateTime startTime;
private LocalDateTime endTime;
private int status;
// 构造函数、getter、setter省略
}
}
4. 关键点解析
FOR UPDATE:这是悲观锁的核心,它确保在事务提交前,其他事务无法修改或锁定查询到的行。innodb_lock_wait_timeout:设置锁等待超时时间,避免事务无限期等待。- 异常处理:
DeadlockLoserDataAccessException:死锁受害者异常,当事务因死锁被MySQL选择为牺牲品时抛出。CannotAcquireLockException:锁获取超时异常,当等待锁的时间超过innodb_lock_wait_timeout时抛出。
- 重试机制:在捕获到上述异常后,进行短暂休眠后重试,最多重试3次。这给了其他事务释放锁的机会,从而避免持续的死锁或超时。
五、 悲观锁 vs 乐观锁:如何选择?
悲观锁并非万能,它适用于写操作频繁、冲突概率高的场景。但在以下情况,乐观锁(Optimistic Locking)可能更合适:
- 读多写少:大多数情况下,数据不会被修改,加锁反而带来性能开销。
- 冲突概率低:如用户信息更新,极少出现两人同时修改同一用户的情况。
- 长事务:悲观锁会长时间持有锁,影响其他事务;乐观锁在提交时检查,不阻塞其他事务。
乐观锁示例(使用版本号)
UPDATE meeting_room_bookings
SET status = 0, version = version + 1
WHERE id = ? AND version = ?;
在Java中,通过检查 affected rows 来判断是否更新成功:
int rows = jdbcTemplate.update("UPDATE ... WHERE version = ?", version);
if (rows == 0) {
// 版本号冲突,需要重试或报错
}
六、 最佳实践与注意事项
- 缩短锁持有时间:尽量在事务中只做必要的数据库操作,避免在事务中执行耗时的业务逻辑(如调用外部API)。
- 保持一致的锁获取顺序:如果需要获取多把锁,始终按照相同的顺序获取,以避免死锁。例如,始终先锁会议室ID较小的那条记录。
- 监控与告警:监控死锁和锁超时的发生频率,如果频繁发生,可能需要调整业务逻辑或数据库架构。
- 合理设置超时时间:
innodb_lock_wait_timeout不应设置过短,否则正常事务可能因短暂延迟而被误杀;也不应过长,否则会影响系统响应性。 - 考虑分布式锁:对于微服务架构,可能需要使用Redis等分布式锁解决方案,而不是单纯的数据库悲观锁。
结语
悲观锁加超时重试是一种经典且有效的并发控制策略,它能够确保在资源竞争激烈的场景下,系统的一致性和可用性。通过合理的异常处理和重试机制,我们可以将死锁和锁超时的影响降到最低,为用户提供流畅的预订体验。
记住,没有银弹。选择悲观锁还是乐观锁,取决于你的具体业务场景。希望这篇指南能帮助你更好地理解并发冲突的解决方案,并在实际项目中灵活应用。
