编程 同一条 UPDATE,Go 返回 0、Node 返回 1:MySQL 的 changed 与 matched 不是一回事

2026-10-26 00:03:57

同一条 UPDATE,Go 返回 0、Node 返回 1:MySQL 的 changed 与 matched 不是一回事

压测没起来的时候,日志里零星冒出几条 version conflict。把 SQL 拎出来手工执行,行在,version 也对得上,Query OK, 0 rows affected 后面跟着 (Rows matched: 1 Changed: 0 Warnings: 0)。重试三次,三次都这个结果。

另一头是反过来的:支付回调重复投递,第二次更新状态位,代码判 RowsAffected == 1 就去记账,于是同一笔订单入了两次账。

两个现象不是同一个 bug,但根在同一处。

根因:affected-rows 有两套语义

MySQL 对 UPDATE 的 affected-rows 返回什么,取决于连接建立时有没有带 CLIENT_FOUND_ROWS 标志。

默认不带。此时返回的是实际被改变的行数(changed)。如果 WHERE 匹配上了行,但 SET 里的新值和旧值完全相等,MySQL 会把这次更新优化掉,直接返回 0。

连上时指定 CLIENT_FOUND_ROWS,返回的是匹配 WHERE 的行数(matched / found)。上面那次 no-op 更新会返回 1。

MySQL C API 文档里写得很直白:

For UPDATE statements, the affected-rows value by default is the number of rows actually changed. If you specify the CLIENT_FOUND_ROWS flag to mysql_real_connect() when connecting to mysqld, the affected-rows value is the number of rows "found"; that is, matched by the WHERE clause.

同一页的返回值说明里还有两条信息容易漏:返回 0 表示 UPDATE 没有记录被改动,也可能是 WHERE 压根没匹配到任何行,或者这条连接上还没执行过查询;返回 -1 是出错(对 SELECT 而言是 mysql_store_result() 之前取值的状态)。服务端的 ROW_COUNT() 和客户端拿到的 affected rows 受同一个 flag 影响()。

所以客户端眼里那个 0,至少混了两件事:没匹配到行,和匹配到了但值没变。乐观锁的判据如果只认这一个数字,两者就永远分不开。

对照一下:

场景默认(changed)CLIENT_FOUND_ROWS(matched)
WHERE 匹配 1 行,SET 的新值与原值相同01
WHERE 匹配 1 行,SET 的新值与原值不同11
WHERE 没有匹配到任何行00
语句执行出错-1-1

各客户端默认值不一样

这是最阴的一层:同一段逻辑,换语言、换驱动,行为就变了。

客户端默认语义切换方式
mysql 命令行 / MySQL C API 直连changedmysql_real_connect() 传 CLIENT_FOUND_ROWS
Go go-sql-driver/mysqlchanged(RowsAffected())DSN 加 clientFoundRows=true
Node mysql2 / mysqljsmatched(affectedRows 是 matched,changedRows 才是 changed)默认 capability flags 已含 CLIENT_FOUND_ROWS
JDBC / Connector/Jmatched(JDBC 兼容语义)加 useAffectedRows=true 切到 changed

Go 侧参数文档:。Node 侧的实测记录:。

JDBC 那条值得单独说一下。Hibernate 的乐观锁依赖 matched 语义,useAffectedRows=true 一开,no-op 更新返回 0,Hibernate 就抛 OptimisticLockException,即使根本没有别人动过这行。StackOverflow 上有完整案例:。

Grafana 走的是另一边。为了和 SQLite / PostgreSQL 对齐(后两者的 UPDATE 不管值有没有真的变,都返回匹配行数),他们在 PR #64070 "SQLStore: Enable clientFoundRows for MySQL connections" 里给所有 MySQL 连接打开了 clientFoundRows。理由写得很实在:MySQL 这套行为会引入隐蔽的幂等 bug——代码无法区分"更新没改动"和"WHERE 没匹配",导致写不出幂等 API。他们顺手审计了整个代码库的 UPDATE 语句,发现绝大多数根本不看 affected rows;6 处靠 updatedAt 时间戳侥幸没触发;只有一处真的用这个值决定日志措辞。PR 地址:。

标准写法为什么天然免疫

UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ?

version 出现在 SET 子句里自增,只要 WHERE 里的 version 条件匹配上,这一行的 version 必然从 v 变成 v + 1,新值和旧值不可能相等。行一定被物理改动,changed 必然等于 1。

这时候 changed 和 matched 在这一条语句上重合了:匹配 1 行 ⇒ 改动 1 行。两种语义下判据都成立——返回 1 是抢到了,返回 0 是版本对不上或行不存在。

成立的前提有两个,缺一不可:version 必须在 SET 子句里自增,WHERE 里必须带 version。少任何一个,这条 UPDATE 就退回"新值可能等于旧值"的普通更新,no-op 的坑立刻回来。

会踩到的四种写法

用 updated_at 当版本号,列上还带 ON UPDATE CURRENT_TIMESTAMP(6)。 同一微秒内的两次写入,或者时间戳被应用层显式设成原值,都可能被判成冲突或误判成功。DATETIME 精度不够时会假匹配,相当于把锁的粒度放到了时间精度上。

状态位 CAS:

UPDATE orders SET status='paid' WHERE id=? AND status='pending'

第一次回调 changed = 1。重复回调第二次:matched = 1,changed = 0,默认语义下返回 0,被判成"已被别人改过",幂等处理是对的。一旦连接开了 clientFoundRows,这里返回 1,代码判成"我这次把它改成 paid 了",重复入账。

靠值比对做 CAS: UPDATE stock SET num=50 WHERE id=1 AND num=50。值相同,直接 0 行,永远抢不到。

ORM 的脏检查。 字段没变化时 Hibernate / MyBatis-Plus 可能压根不发 UPDATE,也可能发一条 no-op UPDATE,取决于实现和配置。如果业务层把 RowsAffected == 1 当成"我抢到了锁",就会在 no-op 这条路径上误判。

clientFoundRows 是双刃剑

把上面几节合起来看,会发现一个绕不过去的冲突:

  • 用 RowsAffected == 1 表示"这次更新由我生效",需要的是 changed 语义。
  • 用 RowsAffected == 1 表示"这条记录在更新前满足前置条件",需要的是 matched 语义。

同一个返回值,两种判据的期望正好相反。Grafana 选 matched,是为了让"幂等 POST"能写成"UPDATE ... WHERE status='pending',返回 1 就继续,返回 0 就当作重复请求",不依赖全局唯一键;Hibernate 默认 JDBC 兼容也是 matched,但它的语义是"这行还在,没人改过",而不是"我改了"。状态位 CAS 那种写法恰恰要分清楚"这行现在是否满足条件"和"我这次有没有真的改动它"。

所以 clientFoundRows 不能全局拍板。它在 Go 里作用于整个连接池,不是单条 SQL——开了它,等于改掉池里所有 UPDATE 的返回值含义。合理的做法是按用途分池:幂等 / 状态机类操作一个池,开 matched;计数 / 水位 / 需要 CAS 冲突信号的场景一个池,保持 changed。池的语义写进 README 或 DSN 常量,别用默认值赌。

另外一条:幂等场景不要靠 affected rows 判断"是否首次"。用幂等键、唯一索引、状态机前置条件去做,affected rows 只能当次要信号。

判定怎么定

  • 用自增整数 version 做 CAS,version 一定进 SET;不要用值比对,不要用时间戳做版本号。
  • 判据固定:RowsAffected == 1 → 抢到;== 0 → 冲突或不存在,业务上先合并处理。需要区分的话再查一次——一次额外 SELECT 比错判便宜得多。
  • 不要解析 info 字符串里的 (Rows matched: N Changed: N) 做业务判断,那是给人看的。

Go 侧的写法

type Conflicted struct{}

func (Conflicted) Error() string { return "version conflict" }

func deduct(db *sql.DB, id int64, ver int64, qty int) (int64, error) {
res, err := db.Exec(
`UPDATE stock SET qty=qty-?, version=version+1 WHERE id=? AND version=? AND qty>=?`,
qty, id, ver, qty,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
if n == 0 {
return 0, Conflicted{}
}
return ver + 1, nil
}

qty>=? 是业务前置条件,不是锁。它让"库存不足"和"版本冲突"都返回 0,想区分必须在同一事务里 SELECT ... FOR UPDATE 复核,或者干脆先判库存。注意在 clientFoundRows=true 的池上跑这段,n == 0 只会表示行不存在或 version 不匹配,语义已经变了,别再复用它做冲突判断。

DSN 切语义长这样:

user:pass@tcp(127.0.0.1:3306)/db?clientFoundRows=true

重试要有限、带抖动。固定 sleep 50ms 在高并发下会把冲突波推到下一个时刻再次对齐撞车。指数退避 + 随机抖动,base 取 520ms、上限 200500ms、尝试 3~5 次、总预算受 ctx deadline 约束:

func Retry(ctx context.Context, fn func(context.Context) error) error {
const (
base = 10 * time.Millisecond
max  = 300 * time.Millisecond
try  = 4
)
var last error
for i := 0; i  max {
d = max
}
d += time.Duration(rand.Int63n(int64(d))) // 抖动,打散同一批请求
select {
case
- `ROW_COUNT()` 与 flag:
- Grafana PR #64070:
- go-sql-driver/mysql `clientFoundRows`:
- mysql2 默认 FOUND_ROWS 实测:
- Hibernate `useAffectedRows` 案例:
- MySQL OCC 一般流程参考:

这两种判据在同一套代码里没法同时满足。你能做的是在连接粒度上选一个,把选了什么写进 README,并且在 code review 时把每一处 `RowsAffected == 1` 当成一个需要解释的信号——它想问的是"我改了这行",还是"这行当时满足条件"。

推荐文章

程序员茄子在线接单