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

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 之后,表里其实并排躺着两个版本:
likes | xmin | xmax | 说明 |
|---|---|---|---|
| 100 | 87 | 105 | 旧版本:87 号创建、被 105 号(A) 废弃 |
| 101 | 105 | 0 | 新版本: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
学习笔记
为什么 let s2 = s1 有时报错,有时不报错
从 C 转 Rust 重写 ACGHub,借用规则我自觉很快就懂了。结果真正把我看懵的,是一个看起来最不该出问题的操作——`let s2 = s1`,它一会儿编译报错,一会儿又安然无事。
学习笔记
乱码不是字坏了,是尺子拿错了
乱码烦了我好多年——打游戏、改配置、传服务器,每次都靠玄学糊弄过去。直到把「字 → 码点 → 字节」这条链拆开,才明白:字从来没坏,是我每次都拿错了解码的那把尺子。
学习笔记
为什么游戏设置里有五种抗锯齿
大一打游戏翻设置,抗锯齿那栏点开是 SSAA / MSAA / FXAA / SMAA / TAA 一长串全是缩写。好奇查了查才发现,它们其实都在跟同一个东西较劲——锯齿,而锯齿的根,是采样不够。