Go 批处理 UPDATE 开了 clientFoundRows=true:判空从静默漏数据变成无限空转
现象:同一条循环,两种失败方式
运维脚本里常见的写法:
for {
res, err := db.Exec(`UPDATE user_profile SET remark = '' WHERE id > 0 LIMIT 1000`)
if err != nil {
return err
}
n, _ := res.RowsAffected()
total += n
if n == 0 {
break
}
}
WHERE id > 0 LIMIT 1000 这个条件不会被 SET 改变。第一轮把前 1000 行 remark 清成空串,第二轮取到的还是同一批 1000 行——这里没有游标推进,SQL 本身不具备分批能力。
连接池的 clientFoundRows 取值决定脚本以哪种方式坏掉:
- 默认(changed rows):第二轮这 1000 行已经是
'',changed = 0,n == 0触发 break。剩下的几百万行永远不会被处理,脚本「成功退出」。这是线上更常见的形态:没有报错,只是数据少了。 clientFoundRows=true(matched rows):第二轮 matched 仍然是 1000,n = 1000,不 break,一直空转。每轮都是同一批行、都是 no-op update。监控上表现为进程 CPU 不高、QPS 稳定压在同一个 UPDATE 上、innodb_rows_updated几乎不动;DBA 侧看到一条 SQL 的Com_update单调上涨而实际写入量不变。脚本要么被超时杀掉,要么被 OOM 杀掉。
为什么:RowsAffected 的语义在建连握手时就定了
MySQL C API 的 mysql_affected_rows():对 UPDATE,默认返回实际改变的行数(changed);如果连接时给 mysql_real_connect() 指定了 CLIENT_FOUND_ROWS,返回的是 WHERE 匹配到的行数(matched)。细节见 MySQL 官方文档 mysql_affected_rows。
INSERT ... ON DUPLICATE KEY UPDATE 一样受影响:默认每行返回 1(新插入)/ 2(更新了已有行)/ 0(已有行被设成它当前的值);置位 CLIENT_FOUND_ROWS 后,最后那种情况返回 1 而不是 0。DELETE 不受影响,行要么在要么不在。
go-sql-driver/mysql 的 DSN 参数 clientFoundRows=true 就是置这个位,对应 Config.ClientFoundRows,注释原文是 “Return number of matching rows instead of rows changed”。仓库:https://github.com/go-sql-driver/mysql。
flag 在握手阶段协商,建连之后改不了。Config 只在建连时生效,不能靠一条 SET 切出来。
这个参数常由基础设施层给出——配置中心的 DSN、环境变量、ORM 默认值——写脚本的人未必知道它开着。而 if n == 0 { break } 这个习惯写法在两种语义下含义完全不同:一个是「没改到东西」,一个是「没匹配到行」。
最小验证:在目标连接上跑一条值不变的 UPDATE
CREATE TABLE t_probe (id INT PRIMARY KEY, v INT NOT NULL);
INSERT INTO t_probe VALUES (1, 7);
UPDATE t_probe SET v = 7 WHERE id = 1;
- 返回 0 行受影响:changed 语义,
CLIENT_FOUND_ROWS未置位。 - 返回 1 行受影响:matched 语义,
clientFoundRows=true。
探针必须走同一条连接,或者同一个连接池,因为 flag 是连接级的,建连后不能改。Go 里可以用 db.Conn(ctx) 拿一条独占连接去跑,别让连接池随机分配。
另一个旁证:mysql CLI 执行完 UPDATE 会打印 info 字符串 (Rows matched: 1 Changed: 0 Warnings: 0),CLI 默认是 changed 语义,可以直接对照。
正确写法:用主键游标推进
判空的依据应该是「有没有取到行」,不是「有没有改到行」。UPDATE 不返回处理到哪一行,所以拆成两步:先 SELECT 主键批次,再按主键更新。
func clearRemark(ctx context.Context, db *sql.DB) error {
const batch = 1000
lastID := int64(0)
for {
rows, err := db.QueryContext(ctx,
`SELECT id FROM user_profile WHERE id > ? ORDER BY id LIMIT ?`, lastID, batch)
if err != nil {
return err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if len(ids) == 0 {
return nil
}
lastID = ids[len(ids)-1]
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
if _, err := db.ExecContext(ctx,
`UPDATE user_profile SET remark = '' WHERE id IN (`+ph+`)`, args...); err != nil {
return err
}
}
}
strings 需要额外 import。想少一次往返,可以按 id 区间切:UPDATE ... WHERE id BETWEEN ? AND ?,边界由自己维护,控制权在调用方手里。
DSN 带不带 clientFoundRows 都不影响这段逻辑,因为它不读 RowsAffected。举例如下:
db, err := sql.Open("mysql",
"user:pass@tcp(127.0.0.1:3306)/app?clientFoundRows=true&parseTime=true")
几条约束:
- 幂等表不要用
RowsAffected() == 0推断「记录不存在」。开了clientFoundRows后,INSERT ... ON DUPLICATE KEY UPDATE对「值没变」的行返回 1,判重逻辑会翻车。需要区分就单独SELECT,同事务内必要时加SELECT ... FOR UPDATE。 - 确实要按 changed 语义判断有没有真实写入,就在 DSN 里显式写
clientFoundRows=false。driver 不写这个参数时等价于 false,但显式写出来才算把契约固定住。 - 迁移或换 DSN 时,把
clientFoundRows当成契约的一部分写进配置注释。
判定清单
- 循环里出现
RowsAffected() == 0 { break }:先确认这条连接池的clientFoundRows值,再谈逻辑对不对。 - UPDATE 的 WHERE 条件会被 SET 改变吗?会,changed 语义能推进;不会,必须有主键游标或 id 区间。
- 这类 SQL 在数据量小的测试环境通常一次就改完,不会暴露问题,上线前至少拿百万行量级跑一遍。
INSERT ... ON DUPLICATE KEY UPDATE的返回值参与判断时,把 0 / 1 / 2 三种情况在两种 flag 下各列一遍。