3. 数据库悲观锁:基于数据库行锁的冲突解决方案

说到任务调度冲突,很多人的第一反应就是「加锁」。没错,锁是最直接的思路。但锁加在哪里、怎么加、加不好会出什么问题,这里面的门道可不少。

我个人习惯把悲观锁比作「排队上厕所」——你进去就把门锁上,别人只能等着。虽然粗暴,但在某些场景下,这反而是最稳妥的办法。

3.1 悲观锁的核心思想

悲观锁,说白了就是「我假设一定会冲突」。每次拿数据的时候,我都认为别人会来改,所以直接锁住它,不让别人碰。

在数据库层面,我们通常用 SELECT ... FOR UPDATE 来实现。这条语句会对查询结果集中的行加上排他锁。其他事务想改这些行?对不起,等着吧。

核心原则:先锁后查,查完即改,改完即释。锁的粒度越小,并发能力越强。

3.2 基于行锁的调度方案

假设我们有一个任务调度表 task_schedule,多个实例同时抢同一个任务。怎么保证只有一个实例能拿到?

来看一个典型的实现:

-- 事务开始
BEGIN;

-- 步骤1:锁定目标行
SELECT * FROM task_schedule 
WHERE task_id = 1001 AND status = 'PENDING'
FOR UPDATE;

-- 步骤2:检查是否已被其他实例抢占
-- 如果返回空行,说明任务已被取走,回滚事务

-- 步骤3:更新状态,标记为「执行中」
UPDATE task_schedule 
SET status = 'RUNNING', worker_id = 'instance-3', version = version + 1
WHERE task_id = 1001 AND status = 'PENDING';

-- 步骤4:提交事务
COMMIT;

这里有个关键点:FOR UPDATE 锁住的是行,不是表。其他实例可以继续查询别的任务行,互不影响。

我的经验:记得在 status 和 task_id 上建联合索引。否则行锁会升级为表锁,性能直接崩掉。我曾经在一个项目里吃过这个亏,上线第一天数据库连接池就被打满了。

3.3 行锁的三种模式

MySQL InnoDB 的行锁其实分三种。我刚开始用的时候也搞混过,这里整理一下:

锁类型 SQL 示例 特点
记录锁(Record Lock) SELECT ... WHERE id = 1 FOR UPDATE 只锁单行,精准但依赖唯一索引
间隙锁(Gap Lock) SELECT ... WHERE id BETWEEN 10 AND 20 FOR UPDATE 锁住范围,防止幻读,但并发度低
临键锁(Next-Key Lock) 记录锁 + 间隙锁的组合 InnoDB 默认,兼顾一致性和并发

嗯,这里要注意:如果你的查询条件没有走索引,InnoDB 会直接锁全表。这不是行锁的问题,是 SQL 写得不好。

3.4 避坑指南:我曾经踩过的三个坑

悲观锁虽然好用,但坑也不少。我一个个说:

  • 死锁:两个事务互相等对方释放锁。我曾经在一个批量调度场景里,实例 A 锁了任务 1 等任务 2,实例 B 锁了任务 2 等任务 1,结果两边都卡死了。解决方案是让所有实例按相同的顺序加锁,比如按任务 ID 升序。
  • 锁等待超时:一个实例持有锁太久,其他实例等不及就报错了。我建议把事务内的操作尽量精简,不要在锁内做远程调用或复杂计算。
  • 连接池耗尽:每个锁住的事务都占用一个数据库连接。如果并发高,连接池很快就被占满。我一般会设置合理的 lock_wait_timeout,比如 5 秒,超时就放弃。

警告:千万不要在锁内调用外部 API 或执行长时间操作。我曾经见过一个同事在 FOR UPDATE 后面调了一个第三方支付接口,结果接口超时 30 秒,整个调度系统直接瘫痪。

3.5 悲观锁 vs 乐观锁:什么时候选谁?

你可能会问:悲观锁和乐观锁,到底用哪个?

我的判断标准很简单:

  • 冲突率高(比如热点任务,每秒几百个实例抢同一个任务)→ 用悲观锁。因为乐观锁的重试成本太高了。
  • 冲突率低(比如任务分散,每个实例各抢各的)→ 用乐观锁。因为悲观锁的锁开销反而成了瓶颈。

说白了,悲观锁适合「抢」的场景,乐观锁适合「领」的场景。

3.6 整体流程示意图

下面这张图展示了基于数据库行锁的调度流程。我画的时候特意把关键决策点标出来了:

基于数据库行锁的任务调度流程 实例启动调度 SELECT ... FOR UPDATE 行是否已被锁定? 等待或重试 获取锁成功 更新状态 + 提交事务 执行任务 是(已被锁) 否(获取锁) 关键:FOR UPDATE 必须在事务内使用,且查询条件必须走索引,否则行锁会升级为表锁

3.7 性能调优建议

最后,分享几个我实际项目中用过的调优参数:

  • innodb_lock_wait_timeout:锁等待超时,默认 50 秒,我一般调到 5-10 秒
  • max_execution_time:单个查询超时,防止慢 SQL 拖垮数据库
  • 事务内只做必要的操作,把业务逻辑尽量放到事务外面
  • 监控 SHOW ENGINE INNODB STATUS 中的锁信息,定期排查死锁

一个小技巧:如果任务表很大,可以按任务 ID 做哈希分表。每个实例只负责特定分片,这样连行锁都不需要了——因为根本不会冲突。当然,这是后话了。