cd ../articles

学习笔记

包进事务,点赞就不会数错了吗

5 min read
两个事务同时把点赞数从 100 改成 101,最终少算了一次。
两个事务同时把点赞数从 100 改成 101,最终少算了一次。

ACGHub 里到处是计数:点赞数、收藏数、回答数。最朴素的写法谁都会——把当前数 SELECT 出来,加一,再 UPDATE 回去。

我一直觉得这没什么问题:只要把这两句包进 BEGIN…COMMIT,事务保证原子性,就不会出错。前阵子被人问到「并发下这样够安全吗」,我还脱口而出:「安全吧?」

这篇就拆这个「安全吧」。它其实不安全。而要讲清楚它为什么不安全,得先把我对 MVCC 的理解也一起纠正了——因为这俩,是连在一起的。

先纠正我对 MVCC 的误解

我以前脑子里的 MVCC 是这样的:写操作先进 pg 的日志(那玩意儿叫什么我一度想不起来——是 ,预写日志),读操作读的是数据库本体;写在日志、读在别处,两边在不同地方,所以不打架

这个图像里,对的部分是:pg 确实有 WAL,改动落盘前先顺序写进 WAL。错的部分是:WAL 管的是「崩溃了能恢复」(持久性),跟「读写为什么不互相阻塞」根本是两码事,我把它俩搅一起了。

真正让读写不打架的是 ——Multi-Version Concurrency Control,多版本并发控制。关键就在「多版本」三个字:

  • 同一行,可以有多个版本同时躺在表里
  • UPDATE 不是原地把旧值覆盖掉,而是写一个新版本、把旧版本留着
  • 每个版本带着 xmin / xmax:标明它是被哪个事务创建的、又被哪个事务废弃的;
  • 每个事务(或语句)拿一个快照,靠版本的 xmin / xmax 判断「你能看到哪个版本」。

xmin / xmax 到底是啥?每行版本上都藏着这两个系统字段:xmin创建它的事务号xmax废弃它的事务号(还没被废弃就是空)。而每个事务有一个递增的事务号(XID)。UPDATE 一行,pg 其实干两件事:给旧版本盖上 xmax = 我的事务号(标记它就此作废),再插一个 xmin = 我的事务号 的新版本。

拿点赞那行举例,事务 A(假设事务号 105)把 likes 从 100 改成 101 之后,表里其实并排躺着两个版本:

likesxminxmax说明
10087105旧版本:87 号创建、被 105 号(A) 废弃
1011050新版本:105 号(A) 创建、还没人废弃

可见性规则就一句话:「由我看得见的事务创建、且还没被我看得见的事务废弃」的那个版本,才是我该看的。 A 提交前,别的事务的快照里 105 还不算「已提交」——于是新版本 101 的 xmin(105) 不可见、旧版本 100 的 xmax(105) 也不可见,结果它们看到的还是 100。等 A 一提交、快照认了 105,才轮到 101 现身。

所以当 A 改了某行、还没提交时,B 去读,看到的是旧版本——不是因为「读写在两个地方」,而是因为那个旧版本物理上还好端端躺在表里,而 B 的快照告诉它「你该看这一个」。写的人在造新版本,读的人在看旧版本,井水不犯河水。等旧版本再没有任何快照需要了,由 VACUUM 清掉(这也是为什么 pg 的表会「膨胀」、需要 VACUUM——旧版本不会自己消失)。

那「包进事务」到底保证了什么

BEGIN…COMMIT 给你的是原子性(这一组语句要么全做、要么全不做)和持久性(提交了就不丢)。这俩都很重要。

原子性 ≠ 没人插队

原子性只保证「我这几条语句作为一个整体,要么全成功要么全回滚」;它保证「我这几条语句执行的中间,别人不能插进来动同一行」。我那句「安全吧」,错就错在把原子性当成了隔离——以为捆成一个事务,中间就没人能掺和了。

点赞 +1 为什么会丢

把两个并发的点赞摆到一条时间线上,问题就露出来了。假设某条帖子 likes 初始是 100:

sequenceDiagram
    participant A as 事务 A
    participant DB as likes 行(初始 100)
    participant B as 事务 B
    A->>DB: SELECT likes
    DB-->>A: 100
    B->>DB: SELECT likes
    DB-->>B: 100
    Note over A: 应用层算 100 + 1 = 101
    A->>DB: UPDATE likes = 101
    Note over A,DB: A COMMIT ✓(likes = 101)
    Note over B: 应用层算 100 + 1 = 101<br/>(基于过时的 100)
    B->>DB: UPDATE likes = 101
    Note over B,DB: B COMMIT ✓(likes 还是 101)
    Note over DB: 结果 likes = 101 ❌<br/>两次点赞应为 102,丢了一次

两个事务都完美原子、都成功提交,可一次点赞凭空蒸发了。这就是

最妙(也最坑)的地方在于:B 为什么会读到旧的 100? 正是因为上面那个 MVCC。B 开始时 A 还没提交,B 的快照里那行就是旧版本 100。于是 B 拿着这个稳定的、过时的 100 去算 +1,对 A 刚刚的改动一无所知,最后用 101 把 A 的 101 又盖了一遍。

换句话说,MVCC 那个「读到稳定快照、不被写阻塞」的好处,恰恰是这里坑你的原因。它让你的读又快又稳,却也让你读到的可能是一个「马上就要过时」的值——你再基于它去写回,就把别人的改动悄悄抹掉了。

怎么才真的安全

关键认识:危险的不是「事务」,是把「读-改-写」拆成两条语句、还把新值放在应用层算好。围绕这点,几种修法:

① 计数就别「读出来再写回」,把算术交给一条 SQL。

UPDATE posts SET likes = likes + 1 WHERE id = 1;

单条 UPDATE 在行级是原子的:两条这样的语句撞上,pg 会让第二条等第一条提交,然后基于最新的行重新算 likes + 1。所以不会丢。计数器、库存这种纯加减,首选就是这一条——根本不给「先读后写」留缝。

② 如果业务必须先读、再据此决定怎么写(比如「库存够才扣」),那就在读的时候锁住行:

BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;  -- 锁住这行
-- 这里判断 stock 够不够、算新值
UPDATE products SET stock = $new WHERE id = 1;
COMMIT;
会给选中的行加锁,别的事务想改它就得排队等你提交——这是悲观锁。

③ 乐观锁:给行加个 version,更新时带上条件,UPDATE … WHERE id = 1 AND version = $old;影响行数为 0 说明被人抢先改了,重试即可。

④ 提高隔离级别到 Repeatable Read 或 Serializable。但要说清楚:这不是「设了就自动安全」——pg 在这些级别下会检测到冲突、让其中一个事务报序列化失败,然后你得自己捕获错误并重试。它帮你「发现」问题,重试还得你来。

顺带认全隔离级别

既然提到级别,就把 pg 的几档串一下(pg 的实现比 SQL 标准还强一些):

级别防住仍可能
Read Committed(pg 默认)脏读不可重复读、丢失更新
Repeatable Read(pg 是快照隔离)脏读、不可重复读、幻读写偏斜(write skew)
Serializable(pg 是 SSI)上面全部代价:冲突时序列化失败,要重试

两个 pg 的小细节值得记:一是 pg 任何级别都不脏读(标准里最低的 Read Uncommitted,pg 直接当 Read Committed 跑);二是 pg 默认是 Read Committed,不是 MySQL InnoDB 那种 Repeatable Read——这条我之前居然没记错,但很多人会想当然。

回头看

我那句「安全吧」,错在把原子性当成了隔离。事务把我那几条语句捆成「要么全记上、要么全不记」,可它没把它们锁成「别人插不进来」。

而我之所以会那么想当然,又跟我对 MVCC 的误解连在一起:我以为读写各在一处、天然不冲突,其实它们看的是同一行的不同版本——正是这个「各看各的版本」,让后写的人拿着旧值,把先写的人的 +1 盖掉了。

ACGHub 里那些计数,我后来都改成了 SET likes = likes + 1 一条语句。事务能保证的,是「我这一笔加法,要么记上、要么不记」;可「这一笔基于的数对不对」,得我自己用对一条原子 SQL、或者一把锁去保证——这是事务替我管的那部分。

// related

作者头像
yen@harvey:~$ exit 0