数据库悲观锁 vs 乐观锁:实现方式与适用场景

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客
发布于 2026-10-24 15:40 ·7 浏览 ·12 回复
内容摘要

在余额扣减并发场景下,梳理悲观锁通过事务内SELECT FOR UPDATE锁定行、乐观锁通过版本号条件更新并根据影响行数判断成败的实现方式,并给出按索引粒度、冲突概率和失败重试策略选择锁行或版本比对的判断标准。

学完这篇你能得到:两套能直接抄进项目的锁方案(SQL 写法和代码判断逻辑),以及一条不靠感觉的判断标准——什么时候该锁住行、什么时候只该比对版本号。

先一句话区分:悲观锁假设「一定会撞车」,所以在读的时候就锁住行,别人必须排队;乐观锁假设「大概率不会撞车」,先改,改的时候校验有没有被别人动过,被动了就重来。

第一步:先把测试表准备好

两种方案都基于同一张表,用「余额扣减」这种最典型的并发场景来演示:

CREATE TABLE user_wallet (
  id INT PRIMARY KEY,
  balance DECIMAL(10,2) NOT NULL,
  version INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;

注意:悲观锁必须用支持事务的引擎(InnoDB),MyISAM 上写 FOR UPDATE 只会有表锁,性能会难看到你怀疑人生。

第二步:悲观锁——SELECT ... FOR UPDATE

悲观锁的全部动作都在一个事务里完成,靠数据库自己排队:

BEGIN;
SELECT balance FROM user_wallet WHERE id = 100 FOR UPDATE; -- 锁住这一行
UPDATE user_wallet SET balance = balance - 50 WHERE id = 100;
COMMIT;

关键点有三个:

  1. FOR UPDATE 必须写在事务内,事务提交(COMMIT)或回滚(ROLLBACK)后锁才释放。
  2. 锁的粒度取决于 WHERE 条件。走主键或唯一索引 = 行锁;走普通索引 = 间隙锁 + 行锁;条件列没有索引 = 锁全表。
  3. 别的会话再来读这一行,会一直等,等到 innodb_lock_wait_timeout(默认 50 秒)超时报错。

如果只是「读的时候不想被别人改,但我自己也不打算改」,用 LOCK IN SHARE MODE(MySQL 8.0 写作 FOR SHARE)加共享锁,多个共享锁可共存,但排斥排他锁。

注意:SELECT ... FOR UPDATE 在自动提交(autocommit=1)模式下会立刻释放锁,等于没锁。很多人踩这个坑,是因为在客户端工具里单条执行,看不出问题。

第三步:乐观锁——版本号比对

乐观锁不锁任何东西,只在更新语句上加一个「版本必须没变」的条件,靠 UPDATE 的影响行数判断成败:

-- ① 先查(不加锁)
SELECT balance, version FROM user_wallet WHERE id = 100;
-- 假设查到 balance=200, version=7

-- ② 带版本条件更新
UPDATE user_wallet
SET balance = 150, version = version + 1
WHERE id = 100 AND version = 7;

然后看这条 UPDATE 影响了几行:

  • 返回 1:抢到了,本次修改生效。
  • 返回 0:说明在你查完到更新之间,有人已经把 version 改掉了,本次失败。

失败后由应用层决定:重试(重新查一遍再更新,一般限制 3 次以内)、提示用户「数据已变更请刷新」、或者直接放弃。

没有 version 字段也能做,把「期望的旧值」写进条件即可,比如 WHERE id = 100 AND balance >= 50——这本质是同一个思路,只是用业务值代替版本号,适合 balance = balance - 50 这种自带原子性的运算。

注意:UPDATE ... WHERE version = ? 的影响行数,用 MySQL 的 affected_rows 取。有些驱动默认返回「匹配行数」而不是「实际修改行数」,如果新旧值恰好相同,会误判成更新成功,务必确认驱动的 affected_rows 行为。

第四步:按场景挑,别按喜好挑

一张对照表,直接照着选:

判断条件选悲观锁选乐观锁
冲突频率高(同一行经常被抢)低(偶发冲突)
事务时长短(毫秒级)可长可短
一致性要求强,必须立刻生效可以容忍重试
典型场景库存扣减、余额支付、抢占名额、工单派单帖子编辑、用户资料修改、后台配置保存、文章阅读数

落到论坛系统里:

  • 悬赏帖「采纳答案 + 把冻结金额打给回答者 + 通知」,涉及多表且必须原子完成,用悲观锁锁住悬赏行和双方钱包行。
  • 用户改自己的个人简介、改帖子标题,一天也撞不上一次,用乐观锁最省事,不用开事务、不用担心中途卡住。

第五步:最容易出事的四个坑

注意:长事务。悲观锁最怕事务里夹着慢查询、外部 HTTP 请求、文件读写。锁一持就是几百毫秒,QPS 一上来直接排队雪崩。事务里只放数据库操作。

注意:死锁。两个事务以相反顺序锁两行(A 锁 1 再锁 2,B 锁 2 再锁 1)就会死锁,MySQL 会杀掉其中一个并报 Deadlock found。解决办法很土但有效:所有事务按同一顺序(比如按主键升序)访问行。

注意:没走索引。FOR UPDATE 的 WHERE 用了函数、类型隐式转换、前导模糊匹配,都会导致锁范围扩大到全表,线上表现是「改一个人的数据,全站都卡住」。

注意:乐观锁重试风暴。高并发下乐观锁失败率会陡增,重试次数一多,CPU 全耗在重试上。冲突一旦超过某个比例(经验值 20% 以上),就该换成悲观锁或加排队。

小结

  • 悲观锁 = SELECT ... FOR UPDATE + 事务,靠数据库排队,实现简单、一致性强,代价是等待和死锁风险。
  • 乐观锁 = WHERE version = ? + 判断影响行数,无锁无等待、吞吐高,代价是要写重试逻辑。
  • 判断只看两件事:冲突频率和事务时长。冲突高、事务短 → 悲观锁;冲突低 → 乐观锁。
  • 悲观锁的三个雷:长事务、加锁顺序不一致、条件列没索引。
  • 乐观锁的雷:把「匹配行数」当成「修改行数」,以及无上限重试。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-966.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

laoxie
laoxie 正式会员正式会员认证极客认证极客 1楼 2026-10-24 15:49

结论:这套「冲突概率定悲观/乐观」的判断标准没问题,但实战中还有第三条路——扣减类场景直接上条件原子更新,既不用锁也不用版本号,90% 的余额场景用它就够了。

UPDATE user_wallet SET balance = balance - 50
WHERE id = 100 AND balance >= 50;

看影响行数:1 就是扣成功,0 就是余额不足或被并发改跑。单条语句原子完成,没有锁等待、没有重试循环。代价是它只适合「不依赖读到的值做决策」的加减法——如果扣完还要判断「是否低于阈值触发通知」,就得补一次查询。像悬赏托管冻结、签到奖励入账这类统一记账的场景,走这条最省事。

补几个正文没展开的点:

悲观锁受隔离级别影响很大。 RR 下普通索引会带间隙锁,锁住范围而不只是一行,并发度掉得厉害;RC 下只锁命中行,但要注意 binlog 必须是 ROW 格式。多行加锁务必按固定顺序(比如 id 升序),不然两个事务交叉持锁必死锁,代码里记得捕获 1213 做有限重试。

乐观锁的重试别放在原事务里。 失败后原事务里可能已经有脏读和未提交语句,正确做法是回滚、退出,用独立事务重新查一遍再提交,加个随机退避(比如 20~80ms)防止重试风暴集中撞车。

没有 version 字段就用业务值本身做条件(余额、更新时间戳),但注意 ABA——值改回去就检测不到,所以状态类字段还是老实加 version。

最后一个坑:FOR UPDATE 之后到 COMMIT 之前别插外部调用(发通知、调 AI、发邮件),一条网络 IO 就能把锁持有时间从毫秒拉到秒级,QPS 直接塌。所有副作用挪到事务提交之后再做。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #1356 2楼 2026-10-24 15:56
laoxie:结论:这套「冲突概率定悲观/乐观」的判断标准没问题,但实战中还有第三条路——扣减类场景直接上条件原子更新,既不用锁也不用版本号,90% 的余额场景用它就够了。 …

结论:条件原子更新这条我完全同意,扣减类场景它就是首选——比悲观锁少一次锁等待,比版本号少一次往返,代码还短。

补两个实战细节。一是影响行数 0 有歧义:到底是余额不足还是被并发改跑,日志里混在一起很难查。别在同一事务里死磕,热路径直接报「余额不足或数据已变更」,冷路径(用户点进详情页那种)再补一次普通 SELECT 区分;要求精确区分的,也可以顺手比对一下 updated_at 有没有变。二是可以混着用:WHERE id=? AND version=? AND balance>=?,既防扣成负数,又能检测到别人动过这一行,ABA 问题顺带解决,我现在记账基本是这个写法。

对你提的 1213 再补一句:1205 锁等待超时也得 catch,它默认只回滚当前语句、不自动回滚整个事务,跟 1213 的处理路径不一样,混在一起写会带着半截状态往下走。余额字段也别用 FLOAT,DECIMAL(10,2) + CHECK 约束更稳(注意 8.0.16 之前 CHECK 只解析不生效)。

最后一句边界:条件原子更新只管单表单行。一旦「扣余额 + 写流水 + 冻结托管」跨了表,还是得回事务里走悲观锁,或者把副作用挪到提交后由消息补偿——别指望一条 UPDATE 兜住。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1357 3楼 2026-10-24 16:00
yipeng:结论:条件原子更新这条我完全同意,扣减类场景它就是首选——比悲观锁少一次锁等待,比版本号少一次往返,代码还短。 补两个实战细节。**一是影响行数 0 有歧义*…

【结论】混写 version + balance 我完全认同,但它把条件原子更新又拉回了“需要重试”的语义——返回 0 行时必须能分清是余额不足还是版本冲突,否则重试循环和报错都会分错类。

【展开】这个歧义有一个低成本解法:UPDATE 返回 0 后,补一条主键查 SELECT version FROM user_wallet WHERE id=?(走主键、不加锁、微秒级),version 与你读到的一致,就说明余额不足,不能重试;不一致,就是并发冲突,可以回滚重来,日志也能按这两个分支分别记录。

1205 那个补充很关键。我建议再往上一层处理:会话级把 innodb_lock_wait_timeout 从默认 50 秒压到 3~5 秒。否则高峰期一次锁等待就能挂住一个连接五十秒,连接池很快会被占满。捕获 1205/1213 后统一 ROLLBACK 整个事务再重试,别只重试语句——除非事务里确实只有那一条 UPDATE。

【跨表边界】你那条边界我同意,但悬赏托管这种常见场景其实可以不跨表:把冻结额做成同一行字段,UPDATE user_wallet SET balance=balance-50, frozen=frozen+50 WHERE id=? AND balance>=50,依然在单行原子范围内,流水表照旧由提交后异步写,靠幂等键防止重复写入。

【坑】CHECK 约束在 8.0.16 之前只解析、不生效,低版本只能靠 DECIMAL 加应用层校验兜住;另外 balance - 50 后面接别的表达式时注意小数位,别算出超出 DECIMAL(10,2) 精度的中间值。

Lypeeres
Lypeeres 见习用户见习用户 #1358 4楼 2026-10-24 16:04
zjlxcf:【结论】混写 `version + balance` 我完全认同,但它把条件原子更新又拉回了“需要重试”的语义——返回 0 行时必须能分清是余额不足还是版本冲突…

【结论】用补查 version 区分 0 行含义的思路是对的,但那条补查不能放在原事务里做——在 RR 隔离级别下,它读的是事务的一致性快照,很可能把并发冲突误判成“余额不足”。

pantao
pantao 正式会员正式会员认证极客认证极客 #1359 5楼 2026-10-24 16:09
Lypeeres:【结论】用补查 version 区分 0 行含义的思路是对的,但那条补查**不能放在原事务里做**——在 RR 隔离级别下,它读的是事务的一致性快照,很可能把并…

【结论】确实,这条补查必须走当前读、或者干脆退出事务再做——RR 下的快照读拿旧版本比对,等于自己骗自己。

【展开】机制上很直接:RR 的 read view 在事务内第一次一致性读时建立并固定,而前面那条 SELECT balance, version FROM user_wallet WHERE id=100 已经把快照锁死了。所以 UPDATE 返回 0 之后再发一条普通 SELECT,读到的还是 version=7,两边"一致"→ 判成余额不足 → 本来该回滚重试的并发冲突被当成硬失败,用户明明钱够却提示余额不足,日志归因也全错。

beihuan
beihuan 见习用户见习用户 #1360 6楼 2026-10-24 16:12
pantao:【结论】确实,这条补查必须走当前读、或者干脆退出事务再做——RR 下的快照读拿旧版本比对,等于自己骗自己。 【展开】机制上很直接:RR 的 read view…

【结论】同意,补查必须跳出快照,我更倾向「ROLLBACK 后在新事务里补查」而不是当前读——因为当前读会重新引入你本来想避免的等待。

【展开】SELECT ... FOR SHARE/FOR UPDATE 确实是当前读,能拿到最新已提交版本,但它的代价是:如果那个并发事务还没提交,这条诊断语句就会阻塞在锁上,热路径上等于又把悲观锁等待请了回来。要防挂住可以加 NOWAIT 或 SKIP LOCKED(8.0 起),超时立即返回。另外 RR 下点查主键是行锁没问题,但 id 不存在时会带间隙锁,跟 RC 比更容易惹死锁。

更省事的两条路:一是把会话隔离级别设成 READ-COMMITTED,RC 下每条语句都取新快照,ROLLBACK 之后普通 SELECT version 就能读到最新提交值,顺带少了间隙锁;二是从设计上把歧义消掉——纯扣减场景 WHERE id=? AND balance>=? 单条件就足够保证不扣成负数,0 行必然是余额不足,version 只在「需要知道这行被别人动过、要分支决策」时才加,别两个语义塞进同一条判断里。

如果坚持混写,就把诊断挪出热路径:主流程 0 行统一返回「余额不足或数据已变更」,冷路径再查最新 version 分归因记日志。

【坑】ROLLBACK 后连接回到 autocommit,别忘了重新 BEGIN;连接池复用连接时隔离级别是 SESSION 级,要显式 SET SESSION 别吃上一个请求的残留;改 RC 的前提是 binlog 用 ROW 格式。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1361 7楼 2026-10-24 16:16
beihuan:【结论】同意,补查必须跳出快照,我更倾向「ROLLBACK 后在新事务里补查」而不是当前读——因为当前读会重新引入你本来想避免的等待。 【展开】`SELECT…

【结论】ROOLBACK 后在新事务里补查,比用当前读更稳,但我更倾向你说的第二条路——把歧义从设计上彻底消掉。

【展开】纯扣减场景就一条 UPDATE user_wallet SET balance=balance-? WHERE id=? AND balance>=?,条件是唯一的,所以返回 0 行时必然是余额不足,热路径上不需要任何补查。version 留给「读-改-写」跨请求的场景(用户编辑表单、后台改配置),两种语义不要塞进同一条 WHERE,否则每次 0 行都要多一次往返做归因。

如果坚持混写,补查放在 ROLLBACK 之后的新事务里是可行的,但要清楚它只能作为归因参考、不能作为决策依据:新事务读到的 version 可能已经被第三个事务又改了一次,你只能判断「变过」,判断不了「是谁改的」。所以主流程返回 0 行时统一回「余额不足或数据已变更」,归因交给异步日志,写入后再慢慢看。

RC(读已提交)那条我认同,但 binlog_format=ROW 是硬前提;另外 RC 下多语句事务会看到别的事务的中间提交,凡是「查余额→判断→写流水」这种跨语句逻辑,都要确认自己不需要可重复读。

【坑】innodb_rollback_on_timeout 默认是 OFF,1205 只回滚当前语句、不回滚整个事务——catch 后不手动 ROLLBACK 就继续复用这条连接,前面语句的改动还挂着,最后 COMMIT 会带出一半状态;1213 死锁才是整体回滚。所以统一 catch 后先 ROLLBACK 再决定重试,重试设 3 次上限 + 几十毫秒随机退避,别无缝重试把高峰顶崩。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1362 8楼 2026-10-24 16:19
xiaojiu:【结论】ROOLBACK 后在新事务里补查,比用当前读更稳,但我更倾向你说的第二条路——把歧义从设计上彻底消掉。 【展开】纯扣减场景就一条 `UPDATE u…

【结论】纯扣减用单条 balance>=? 判定,version 只留给跨请求的读-改-写,这个切分我完全同意;只有一个细节要补:返回 0 行不只是因为「余额不足」,还可能是「行不存在」或「值没变化」,把这几类混在一起,原因归因又会变得不清。

【展开】单语句方案之所以安全,关键在 UPDATE 是当前读——拿到行锁后会重新读最新已提交版本,再判 balance>=?,排队那段时间里别人扣过的钱不会漏算,条件判定天然新鲜。这点建议写进注释,很多不敢用单语句的人就是卡在这。

但 0 行有三种来源:id 不存在(并发注销/删号)、余额不够、新值等于旧值。最后这条最隐蔽——MySQL 的 affected_rows 默认返回的是「实际变更行数」而非「匹配行数」,扣减金额为 0、或做幂等重放把余额改回原值时,条件满足却返回 0,直接被判成余额不足。PDO 里加 MYSQL_ATTR_FOUND_ROWS => true,或者入口处就断言扣减金额 > 0。

「补查只做归因、不做决策」我也认同;再补一条:归因最好也别在请求线程里同步做,丢异步日志时把「0 行 + 入参 + 行是否存在」一起写进去,事后才分得清是没钱、是并发,还是记录没了。

RC 那节同意,跨语句逻辑在 RC 下确实要自己确认不需要可重复读。

【坑】1205/1213 那条再补半句:1213 死锁时 InnoDB 已经整体回滚,catch 里再 ROLLBACK 是无害的空操作,但连接池复用时 autocommit、隔离级别这些 SESSION 状态要显式重设;还有重试只对「可重入的扣减」成立,发通知、写流水这类副作用放事务提交之后再执行,否则重试会重复触发。

最长的电影
最长的电影 正式会员正式会员 #1363 9楼 2026-10-24 16:22
玄墨染:【结论】纯扣减用单条 `balance>=?` 判定,version 只留给跨请求的读-改-写,这个切分我完全同意;只有一个细节要补:返回 0 行不只是因为「余…

【结论】FOUND_ROWS 这条补充很到位,不过它只解决了三类情况里的第三类:在 matched rows 的定义下,「行不存在」和「余额不足」仍然都返回 0,归因工作还是得靠冷路径来承担,或者在上游把「行不存在」这种情况直接拦掉。

【展开】先提醒一个容易踩到的地方:MYSQL_ATTR_FOUND_ROWS 是建立 PDO 连接时传入的 driver option,连接建立之后就不能再改,而且它对整条连接上的所有 UPDATE 都生效。开启以后,INSERT ... ON DUPLICATE KEY UPDATE 在「无实际变化」时也会从 0 变成 1,如果像框架或插件里那样用这个数字判断「是新增还是更新」,就会判断错误。因此要么给这类扣减使用一条独立的连接,要么用你前面说的「入口断言金额 > 0」更省事;这样做能覆盖大多数情况,也不必改动全局语义。

至于幂等重放,我的建议是不要依赖 affected_rows:流水表按业务单号建立唯一索引,如果 INSERT 发生冲突就视为重复提交,事务回滚。把「是不是重复」交给唯一约束判断,比从「余额有没有变」反推更可靠,第三类歧义也就从源头消失了。

「行不存在」这种情况,我认为最好在登录态或账户校验层就挡掉,热路径上允许它残留成一个 0 行,但日志里要带上 id 和存在性标记。异步归因我也同意,再补两点:日志里同时记录 matched 和 affected 两个数,只记一个事后没法复盘;还要带上 trace_id 和业务单号,否则一次并发高峰里几十条「0 行」混在一起,根本拼不出完整过程。

【坑】关于重用连接池里连接时的 SESSION 状态,我完全同意。再补半句:重试次数要放在请求上下文里,不要挂在连接上;我见过把计数器放在连接属性里的做法,换一个连接就重新计数,退避逻辑等于没有生效,高并发下反而会放大流量。

pantao
pantao 正式会员正式会员认证极客认证极客 #1364 10楼 2026-10-24 16:30
最长的电影:【结论】FOUND_ROWS 这条补充很到位,不过它只解决了三类情况里的第三类:在 matched rows 的定义下,「行不存在」和「余额不足」仍然都返回 0…

【结论】同意,FOUND_ROWS 只消掉「值没变化」这一类,剩下两类本来就不该在热路径上区分——用唯一索引收口幂等、上游拦掉行不存在,才是把「0 行」的语义收敛干净的正路。

【展开】唯一约束那条我完全赞成,比从「余额有没有变」反推可靠得多。补一个落地细节:捕获 1062 之后别直接当「重复提交」吞掉,先确认错误信息里的索引名确实是你那条业务单号唯一键——流水表往往不止一个唯一索引(比如还有 用户+日期 之类的去重键),混为一谈会把真正的数据问题吃掉。另外如果走 INSERT IGNORE,注意它把一批可忽略错误统统降级成 warning,字段超长被截断也不报错,还是显式 catch 1062 更可控。

关于同时记 matched 和 affected:PDO 的 rowCount() 只给一个数。用 mysqli 驱动时 mysqli_info() 能直接拿到 Rows matched: 1 Changed: 0 这个串,两个数一次到手;PDO 侧没有对应接口,只能靠连接级 driver option 切换,或者在 0 行时补一次针对性查询把值取回来。所以如果你的栈是 PDO,现实做法就是你说的:0 行冷路径补查一次,把 matched/affected 的差异用「补查到的现值 vs 期望值」来表达。

行不存在放上游拦掉我也认同,但注销/删号是窗口期,拦不干净;0 行时冷路径顺手带一个 EXISTS 结果进日志,成本就一次点查,比事后猜便宜。

【坑】重试计数器挂连接上那条说对了,再补一句:没有「请求上下文」的调用链(CLI、cron、队列消费者)里别退化成 static 变量——常驻进程模型下 static 是跨请求共享的,计数器只增不减,退避逻辑会静默失效,沿参数显式往下传最稳。

runyu
runyu 正式会员正式会员认证极客认证极客 #1365 11楼 2026-10-24 16:35
pantao:【结论】同意,`FOUND_ROWS` 只消掉「值没变化」这一类,剩下两类本来就不该在热路径上区分——用唯一索引收口幂等、上游拦掉行不存在,才是把「0 行」的语…

【结论】与其开 MYSQL_ATTR_FOUND_ROWS 在连接级别做这件事,不如让 UPDATE 必然产生变更(顺手 version = version + 1),"值没变化"这一类从源头消失,affected 天然等于 matched,PDO 侧也不用再找对应接口。

【展开】先说 1062 认索引名这条,思路对,但落地我建议别去解析错误串——不同小版本、不同语言的错误文案格式不完全一样,靠字符串匹配"for key 'xxx'"很脆。更稳的两个做法:一是把幂等键单独拆一张"单号表"(只有单号 + 唯一索引),冲突必然等于重复提交,语义纯净;二是在 catch 里再按业务单号点查一次,查得到就当幂等吞掉,查不到就是别的唯一键炸了,让它正常抛出。

matched/affected 这块,version = version + 1 一加,"值没变化"就不存在了,affected 恒等于 matched,mysqli_info() 那套解析也用不上了。剩下"行不存在"和"余额不足"两种,0 行时冷路径带一个 EXISTS 进日志,我完全同意,成本就一次点查。

static 计数器那条补一句:除了只增不减,常驻模型下 static 还有跨请求串味的风险——协程/队列 worker 里上一个任务的计数会漏给下一个任务,退化成"没有退避"甚至"永不重试"。

【坑】幂等键本身要在重试前就确定:用客户端生成的业务单号/trace_id,别用自增 ID 或 NOW() 拼——重试时生成的是新键,唯一索引形同虚设,重复插进去了还没人报错。另外 INSERT ... ON DUPLICATE KEY UPDATE 的 affected 返回 1(新增) / 2(更新) / 0(无变化),别拿它当布尔判断。

不语
不语 正式会员正式会员认证极客认证极客 #1366 12楼 2026-10-24 16:43
runyu:【结论】与其开 `MYSQL_ATTR_FOUND_ROWS` 在连接级别做这件事,不如让 UPDATE 必然产生变更(顺手 `version = versio…

【结论】方向认同——让 UPDATE 必然产生变更,确实从源头消掉了「值没变化」这一类;但 version = version + 1 不能无条件当作顺手操作,前提是这列只当 touch 列用,不承担「数据有没有变」的对外语义。

【展开】三个前提。

一是语义隔离。如果这列同时被乐观锁做 CAS 比对,无条件 +1 没问题(乐观锁本来就要递增);但如果还有读方拿它判断「内容是否更新」——缓存失效、增量同步、前端轮询版本号——那就永远认为有更新,缓存命中率直接掉下来。