文章目录
- 0.背景
- [1. 实现原理](#1. 实现原理)
- [2. 核心要点](#2. 核心要点)
- [3. 完整使用流程](#3. 完整使用流程)
- [4. 优缺点分析](#4. 优缺点分析)
- [5. 适用场景](#5. 适用场景)
- [6. 与其他方案的对比](#6. 与其他方案的对比)
- [7. 总结](#7. 总结)
- 参考文献
0.背景
比如使用 MySQL 的 locked_at 字段(bigint unsigned 时间戳)来实现分布式锁或防止并发处理,是一种基于数据库行锁 + 乐观锁 的常见设计。它不依赖外部中间件(如 Redis),实现简单,非常适合低并发、对性能要求不苛刻的后台任务。
核心思想是:获取锁就是尝试更新 locked_at 字段 ,利用 MySQL 的原子更新特性保证同一时间只有一个线程/进程能更新成功。
1. 实现原理
假设有一张任务表 jobs,结构如下:
sql
CREATE TABLE `jobs` (
`id` bigint NOT NULL AUTO_INCREMENT,
`status` tinyint NOT NULL COMMENT '0=待处理 1=处理中 2=已完成',
`locked_at` bigint unsigned DEFAULT NULL COMMENT '锁定时间戳(毫秒),NULL表示未锁定',
`locked_by` varchar(100) DEFAULT NULL COMMENT '锁定者标识(IP/机器名/PID)',
`retry_count` int DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_status_lock` (`status`, `locked_at`)
);
获取锁的 SQL(关键操作):
sql
-- 原子操作:尝试锁定一条待处理的任务
UPDATE jobs
SET
locked_at = UNIX_TIMESTAMP(NOW(3)) * 1000, -- 当前毫秒时间戳
locked_by = 'service-a:pid12345',
status = 1 -- 可选:更新状态
WHERE
status = 0 -- 待处理
AND (locked_at IS NULL -- 从未被锁定
OR locked_at < UNIX_TIMESTAMP(NOW(3)) * 1000 - 60000) -- 锁超时(60秒)
LIMIT 1;
UPDATE语句在 MySQL 中具备行锁原子性,当多个进程同时执行时,只有一个能成功更新,获取到锁。- 成功更新的行数 (
rows affected) 为 1,表示获取锁成功。
释放锁的 SQL:
sql
-- 只有当自己的锁才能释放
UPDATE jobs
SET locked_at = NULL, locked_by = NULL, status = 0
WHERE id = 123 AND locked_by = 'service-a:pid12345';
2. 核心要点
- 原子性 :
UPDATE ... WHERE是原子操作,数据库行锁确保了并发安全。 - 超时机制 :通过
locked_at时间戳判断锁是否过期,防止死锁。 - 锁归属 :通过
locked_by记录锁持有者,确保只有加锁者能解锁,避免误删。
3. 完整使用流程
go
type JobLocker struct {
db *sql.DB
}
// TryLock 尝试获取锁,返回是否成功及锁定的任务ID
func (l *JobLocker) TryLock(lockerID string, timeoutMs int64) (jobID int64, err error) {
// 1. 尝试锁定
result, err := l.db.Exec(`
UPDATE jobs
SET locked_at = ?, locked_by = ?, status = 1
WHERE status = 0
AND (locked_at IS NULL OR locked_at < ?)
LIMIT 1
`, time.Now().UnixMilli(), lockerID, time.Now().UnixMilli()-timeoutMs)
if err != nil {
return 0, err
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return 0, nil // 没抢到锁
}
// 2. 查询被锁定的任务ID
var id int64
err = l.db.QueryRow(`
SELECT id FROM jobs
WHERE locked_by = ? AND locked_at > ?
`, lockerID, time.Now().UnixMilli()-5000).Scan(&id)
return id, err
}
// Unlock 释放锁
func (l *JobLocker) Unlock(jobID int64, lockerID string) error {
_, err := l.db.Exec(`
UPDATE jobs
SET locked_at = NULL, locked_by = NULL, status = 0
WHERE id = ? AND locked_by = ?
`, jobID, lockerID)
return err
}
4. 优缺点分析
| 优点 | 缺点 |
|---|---|
| 实现简单:无需引入额外组件 | 性能瓶颈:不适合高并发场景,数据库压力大 |
| 可靠性高:基于 ACID 事务保证 | 依赖时钟:需要服务器时间准确 |
| 易于调试:锁状态可直接 SQL 查询 | 清理负担:需要定时任务清理过期锁 |
| 事务友好:可与其他操作组合 | 连接池压力:长任务占用数据库连接 |
5. 适用场景
- 分布式定时任务调度:多实例环境下,确保同一任务只被执行一次。
- 消息消费幂等控制:防止消息被重复消费。
- 后台数据处理:如数据归档、报表生成等低频操作。
- 并发量较低的场景(QPS < 100)。
6. 与其他方案的对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| MySQL 乐观锁 | 实现简单,无外部依赖 | 性能较低,有单点风险 | 低并发,简单任务 |
| Redis 分布式锁 | 高性能,支持自动过期 | 需要额外组件,实现复杂 | 高并发,要求响应快 |
| ZooKeeper | 强一致性,支持故障转移 | 重量级,维护成本高 | 对一致性要求极高 |
7. 总结
使用 locked_at 字段实现 MySQL 分布式锁是工程中非常实用的技巧 。它虽然不如 Redis 或 ZooKeeper 那样高性能,但对于大多数内部后台管理系统、定时任务、低频并发控制 场景,是一种简单、可靠、低成本的解决方案。
关键点是正确利用 UPDATE ... WHERE 的原子性,并设计好超时和归属机制。
参考文献
《锁系列三:优雅的通过数据库行锁代替Redis实现分布式锁》
《详解分布式锁的三种实现方式:MySQL vs Redis vs ZooKeeper》