一、RC 的主要代价(风险点)
- 不可重复读
同一事务内,两次读到同一行可能不同(别人已提交修改)。 - 幻读(范围读出现/消失新行)
同一事务内,范围查询两次结果集不一致(别人插入/删除了新行)。 - 丢更新(最后写入覆盖)
两个会话读到旧值后各自改写,后写覆盖先写。 - 写偏斜/跨行约束失效
需要跨行/聚合不变量(“每天最多 N 条”、“至少一人值班”)时,单行锁护不住范围。 - 分页/扫描不稳定
高并发下,基于OFFSET的分页易重复/漏读;基于非唯一列排序易抖动。
二、补救模式与落地代码
总体思路:把强一致的点“精准加固” ,其余用 RC 获得更好的吞吐与尾延迟。
1. 行更新防“丢更新” —— 条件更新 / 乐观锁(推荐)
方案 A:条件更新(CAS)
-- 假设要把 profile.name 改为 :newName,要求基于“我看到的旧值”更新
UPDATE user_profile
SET name = :newName, updated_at = NOW()
WHERE id = :id AND name = :oldName; -- 条件写入
-- 受影响行数=1 表示成功;=0 表示并发冲突(别人先改了)
方案 B:版本号/时间戳乐观锁
ALTER TABLE user_profile ADD COLUMN version INT NOT NULL DEFAULT 0;
UPDATE user_profile
SET name = :newName, version = version + 1
WHERE id = :id AND version = :oldVersion;
Spring/JDBC 伪代码
int updated = jdbc.update("""
UPDATE user_profile SET name=?, version=version+1
WHERE id=? AND version=?
""", newName, id, oldVersion);
if (updated == 0) throw new ConcurrentModificationException("conflict");
代价:需要拿到“旧值”或“旧版本”。
好处:不需要长锁,冲突时快速失败,吞吐友好。
2. 余额/库存/配额 —— 条件自减(原子不透支)
-- 扣减 3 件库存,防透支
UPDATE sku
SET stock = stock - 3
WHERE id = :skuId AND stock >= 3;
-- rows_affected=1 才算成功;否则库存不足或并发失败
扣减成功后,再插入明细/消息(见 Outbox 模式)
START TRANSACTION;
-- 1) 扣减库存
UPDATE sku SET stock=stock-3 WHERE id=? AND stock>=3;
-- 检查 rows_affected
-- 2) 写订单明细
INSERT INTO order_item(...) VALUES (...);
-- 3) 写出站事件(Outbox)
INSERT INTO outbox(event_id, topic, payload, status) VALUES(?,?,?, 'NEW');
COMMIT;
3. 任务领取/队列拨号 —— 锁定读取 + SKIP LOCKED
MySQL 8 支持
NOWAIT/SKIP LOCKED,RC 下很好用。
-- 建议索引:status + priority + id
CREATE INDEX idx_task_pick ON task(status, priority, id);
START TRANSACTION;
-- 多 worker 并发领取,互不阻塞
SELECT id
FROM task
WHERE status = 'NEW'
ORDER BY priority DESC, id ASC
LIMIT 50
FOR UPDATE SKIP LOCKED; -- 跳过已被他人锁住的行
-- 标记处理中
UPDATE task SET status='DOING', picked_by=:worker, picked_at=NOW()
WHERE id IN (..上一步结果..);
COMMIT;
好处:RC 不怎么加 gap 锁,SKIP LOCKED 能极大降低热点竞争与死锁。
4. 去重/幂等 —— 唯一键 + UPSERT
ALTER TABLE payment ADD UNIQUE KEY uk_idem(biz_idempotency_key);
-- 失败重试 / 多次到达也只应用一次
INSERT INTO payment(biz_idempotency_key, amount, status)
VALUES(?, ?, 'NEW')
ON DUPLICATE KEY UPDATE
-- 幂等语义:重复则不改变业务数据(或仅回填幂等安全字段)
status = VALUES(status);
消息幂等:消费者侧也建 uk(event_id),消费前插入“已处理表”,失败回滚即可。
5. 跨行/聚合不变量 —— 用约束物化 或 短暂提升隔离级别
方案 A:物化为唯一约束
需求:同用户每天最多一条“日报”。
-- 约束 (user_id, day) 唯一,直接消灭“幻读 + 写偏斜”
ALTER TABLE daily_report
ADD UNIQUE KEY uk_user_day(user_id, day);
-- 写入时自然串行化
INSERT INTO daily_report(user_id, day, content)
VALUES(?, ?, ?)
ON DUPLICATE KEY UPDATE content = VALUES(content);
方案 B:哨兵行/聚合锁
需求:每小时最多 100 条某类型记录。
做一个 quota 表的计数行,用行锁串行:
-- 聚合计数行(type, hour)唯一
INSERT INTO quota_counter(type, slot, cnt)
VALUES(:type, :hour, 0)
ON DUPLICATE KEY UPDATE cnt = cnt;
START TRANSACTION;
-- 锁住计数行
SELECT cnt FROM quota_counter
WHERE type=:type AND slot=:hour
FOR UPDATE;
-- 达标则拒绝,否则递增 + 写业务行
UPDATE quota_counter SET cnt = cnt + 1
WHERE type=:type AND slot=:hour;
INSERT INTO biz_table(...) VALUES (...);
COMMIT;
方案 C:局部提级(仅这一笔)
真的需要“无幻读范围锁”(比如“扫描区间并禁止插入”),临时升到 RR/Serializable:
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
-- 需要 next-key/gap 锁的那几条语句
SELECT ... FROM t WHERE k BETWEEN ? AND ? FOR UPDATE;
-- ...
COMMIT;
-- 会话仍可还原为 RC
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
6. 分页/扫描稳定性 —— 键集分页(Keyset Pagination)
远离
LIMIT offset, size;用游标值接着扫,排序必须包含唯一尾键。
-- 索引: (created_at, id)
SELECT id, title, created_at
FROM article
WHERE (created_at, id) < (:lastCreatedAt, :lastId) -- 二元游标
ORDER BY created_at DESC, id DESC
LIMIT 20;
好处:RC 下即使有插入/删除,也不会重复/漏读;且不深分页。
7. 只读热点列表 —— 快照直读 + 允许微观不一致
列表页可以不加锁,直接 RC 读取(语句级快照),配合缓存/CDN,牺牲极短暂一致性换吞吐。
8. Outbox(库存/订单 + 消息可靠投递)
事务内写业务 + outbox;后台异步可靠投递,避免“写库成功但消息丢”。
START TRANSACTION;
-- 业务写入/更新
INSERT INTO orders(id, ...) VALUES (...);
-- 记录出站消息
INSERT INTO outbox(event_id, topic, payload, status, created_at)
VALUES(UNHEX(REPLACE(UUID(),'-','')), 'order.created', JSON_OBJECT(...), 'NEW', NOW());
COMMIT;
-- 异步投递器循环:
-- 1) 取 NEW 事件 FOR UPDATE SKIP LOCKED
-- 2) 发 MQ
-- 3) 标记 SENT
三、把“默认代价”转换为“工程决策表”
| 风险 | 现象 | 典型场景 | 推荐补救 |
|---|---|---|---|
| 不可重复读 | 同行两读不一致 | 后台列表/详情 | 允许;强一致点用 FOR UPDATE |
| 幻读 | 范围两读有新增 | 去重、配额、唯一性 | 唯一约束/UPSERT、哨兵行;必要时局部 RR |
| 丢更新 | 后写覆盖先写 | 用户资料、配置改写 | CAS/乐观锁、条件更新 |
| 写偏斜 | 跨行不变量破坏 | 值班/配额/最大 N 条 | 唯一键物化;聚合行 FOR UPDATE |
| 分页抖动 | 重复/漏读 | 高并发列表 | Keyset 分页 (order by x, id) |
| 死锁/锁等待 | 并发卡顿 | 任务领用、热点区 | RC + SKIP LOCKED、小批量 |
四、会话/事务级别设置小抄
-- 全局/会话设置为 RC(按需)
SET GLOBAL transaction_isolation='READ-COMMITTED';
SET SESSION transaction_isolation='READ-COMMITTED';
-- 单事务临时提升(只包住必要语句)
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
-- ...
COMMIT;
五、两段式“先定位起点,再按游标推进”的稳定扫描(结合你前面的发放重试场景)
-- 索引:(start_time, id)
-- 1) 首批:时间窗口 + 状态,精确定位(小扫面)
SELECT id, start_time
FROM grant_record
WHERE start_time >= NOW() - INTERVAL 7 DAY
AND status IN (0,2)
ORDER BY start_time, id
LIMIT 1000;
-- 2) 后续:二元游标推进(稳定无重复/漏读)
SELECT id, start_time
FROM grant_record
WHERE start_time >= NOW() - INTERVAL 7 DAY
AND status IN (0,2)
AND (start_time, id) > (?, ?)
ORDER BY start_time, id
LIMIT 1000;
若要并发领取再处理:加
FOR UPDATE SKIP LOCKED并写回“处理中”状态。
最后一句话
- RC 的“默认代价” = 放弃强一致的范围/可重复语义;
- 工程上的“补交” = 在确切需要的地方用:条件写/乐观锁、唯一键 + UPSERT、
FOR UPDATE SKIP LOCKED、哨兵计数/物化约束、局部提级 + Keyset 分页。
这样既拿到 吞吐/尾延迟,也能精准守住关键一致性。
如果觉得有用就点赞收藏加关注吧!