Innodb RC模式先如何实现精准加固

69 阅读6分钟

一、RC 的主要代价(风险点)

  1. 不可重复读
    同一事务内,两次读到同一行可能不同(别人已提交修改)。
  2. 幻读(范围读出现/消失新行)
    同一事务内,范围查询两次结果集不一致(别人插入/删除了新行)。
  3. 丢更新(最后写入覆盖)
    两个会话读到旧值后各自改写,后写覆盖先写。
  4. 写偏斜/跨行约束失效
    需要跨行/聚合不变量(“每天最多 N 条”、“至少一人值班”)时,单行锁护不住范围。
  5. 分页/扫描不稳定
    高并发下,基于 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 分页。
    这样既拿到 吞吐/尾延迟,也能精准守住关键一致性

如果觉得有用就点赞收藏加关注吧!