数据库知识点
乐观锁(Optimistic Locking)
一句话定义
乐观锁是一种并发控制 策略,它假设数据在读取和更新之间很少发生冲突 ,因此不加锁,而是在提交更新时才检查数据是否被其他事务修改过。
核心思想
"先干活,提交时再验证"。大多数时候没人抢,所以不加锁;真有冲突,让最后提交的人失败重试。
工作机制(常见实现)
通常依赖版本号(version) 或时间戳:
- 读取数据,同时拿到 version = 1
- 业务处理(可能耗时较久)
- 更新时 WHERE id = X AND version = 1
- 如果受影响行数 = 0 → 说明被别人改过了 → 失败/重试
- 如果受影响行数 = 1 → 成功,同时 version 自增为 2
举例(电商下单)
sql
-- 1. 读取库存
SELECT stock, version FROM product WHERE id = 1001;
-- 假设得到 stock=10, version=1
-- 2. 用户A和用户B同时下单,各自计算 stock-1
-- 3. 用户A先提交:
UPDATE product
SET stock = 9, version = version + 1
WHERE id = 1001 AND version = 1;
-- 成功,version 变为 2
-- 4. 用户B再提交:
UPDATE product
SET stock = 9, version = version + 1
WHERE id = 1001 AND version = 1;
-- 影响行数 = 0,version 已经变成 2,条件不匹配 → 失败,需重试
乐观锁 vs 悲观锁
| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 假设 | 冲突很少 | 冲突经常发生 |
| 加锁时机 | 不加锁,提交时检查 | 读取时就加锁 |
| 适用场景 | 读多写少 | 写多读少 |
| 性能 | 高(无锁阻塞) | 低(锁等待) |
| 实现 | 版本号、CAS | SELECT ... FOR UPDATE |
| 失败处理 | 重试或回退 | 阻塞等待 |
常见应用场景
- 电商秒杀:库存扣减,版本号防超卖
- 分布式系统:无中心节点的并发协调
- 配置更新:多人可能同时改同一配置
- ORM 框架 :MyBatis-Plus 的
@Version注解、Hibernate 的乐观锁 - Redis/MongoDB :用
WATCH/version字段实现
实现要点
typescript
// 伪代码:典型乐观锁更新
async function updateWithOptimisticLock(id: string, updater: (data: T) => T) {
const current = await db.findById(id);
const newData = updater(current);
const result = await db.update(
{ id, version: current.version }, // 关键:带上旧版本号
{ ...newData, version: current.version + 1 }
);
if (result.affectedRows === 0) {
throw new ConflictError('数据已被修改,请重试');
}
return newData;
}
注意事项
- 重试机制:失败后必须有重试策略(限制重试次数)
- ABA 问题:单纯版本号可解决;若用时间戳需注意时钟漂移
- 不适合高冲突场景:频繁重试反而比悲观锁更慢
版本号起始值(常见做法)
版本号只是一个递增的标记,具体从几开始,完全看业务设计:
核心认知 :数字大小不重要,关键是版本号必须单调递增 ,且更新时 WHERE 必须带上读到的旧版本号。
sql
-- 推荐写法:NOT NULL DEFAULT 0,避免 NULL 带来的三值逻辑问题
ALTER TABLE product ADD COLUMN version INT NOT NULL DEFAULT 0;
悲观锁
FOR UPDATE 是 SQL 的锁定语法 ,通常跟在 SELECT 语句后面,用于实现悲观锁。
例如:
sql
SELECT *
FROM account
WHERE id = 1
FOR UPDATE;
它的含义是:
查询
id = 1这条数据,同时对它加排他锁(X Lock)。
必须结合事务理解
通常完整写法是:
sql
BEGIN;
SELECT *
FROM account
WHERE id = 1
FOR UPDATE;
UPDATE account
SET balance = balance - 100
WHERE id = 1;
COMMIT;
执行过程:
text
BEGIN
↓
SELECT ... FOR UPDATE
↓
查询数据 + 对数据加锁
↓
UPDATE
↓
COMMIT
↓
释放锁
假设事务 A 已经执行:
sql
SELECT * FROM account WHERE id = 1 FOR UPDATE;
这时事务 B 又执行:
sql
SELECT * FROM account WHERE id = 1 FOR UPDATE;
由于 A 还没有 COMMIT,B 通常会:
text
事务 A:获得锁 ───── UPDATE ───── COMMIT
↑
事务 B: 等待等待等待... ───→ 获得锁
所以可以简单记住:
SELECT ... FOR UPDATE= 查询数据,并把查询到的数据锁住,直到当前事务提交或回滚。
另外,FOR UPDATE 不是 UPDATE 语句。它仍然是一个 SELECT 查询的附加子句,只是告诉数据库:"我接下来准备修改这些数据,所以先锁住它们。"
悲观锁为什么必须结合事务?
SELECT ... FOR UPDATE 获取的锁需要有明确的生命周期,而这个生命周期通常由事务控制:
sql
BEGIN;
SELECT * FROM account
WHERE id = 1
FOR UPDATE; -- 获取锁
UPDATE account
SET balance = balance - 100
WHERE id = 1;
COMMIT; -- 提交事务,同时释放锁
可以理解为:
text
BEGIN
↓
事务开始
↓
SELECT ... FOR UPDATE
↓
获取并持有锁
↓
业务处理 / UPDATE
↓
COMMIT 或 ROLLBACK
↓
事务结束、释放锁
如果查询后锁马上释放,就无法保护后续的"读取 → 判断 → 修改"整个过程,因此悲观锁通常需要放在事务中。
BEGIN、COMMIT、ROLLBACK
| 语句 | 含义 |
|---|---|
BEGIN / START TRANSACTION |
开启事务 |
COMMIT |
提交事务,让修改正式生效 |
ROLLBACK |
回滚事务,撤销本次事务中的修改 |
一个事务通常有两种结局:
text
BEGIN
↓
执行 SQL
↓
┌──────────────┐
↓ ↓
COMMIT ROLLBACK
成功提交 失败回滚
↓ ↓
└──────┬───────┘
↓
事务结束
释放相关锁
一句话记忆
事务用
BEGIN开始,用COMMIT提交或ROLLBACK回滚;FOR UPDATE获取的锁通常会一直持有到事务结束,从而保证整个过程不被其他冲突事务干扰。
数据库中的 DECIMAL 字段
DECIMAL 是数据库中用来存储精确小数的字段类型,专门为金额、价格、汇率等不能丢精度的数值设计。
数据库字段 必须用 DECIMAL,不要用 FLOAT/DOUBLE,金融场景禁浮点(精度丢失)