编程 Go 批处理 UPDATE 开了 clientFoundRows=true:判空从静默漏数据变成无限空转

2026-11-06 00:04:12

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")

几条约束:

  1. 幂等表不要用 RowsAffected() == 0 推断「记录不存在」。开了 clientFoundRows 后,INSERT ... ON DUPLICATE KEY UPDATE 对「值没变」的行返回 1,判重逻辑会翻车。需要区分就单独 SELECT,同事务内必要时加 SELECT ... FOR UPDATE。
  2. 确实要按 changed 语义判断有没有真实写入,就在 DSN 里显式写 clientFoundRows=false。driver 不写这个参数时等价于 false,但显式写出来才算把契约固定住。
  3. 迁移或换 DSN 时,把 clientFoundRows 当成契约的一部分写进配置注释。

判定清单

  • 循环里出现 RowsAffected() == 0 { break }:先确认这条连接池的 clientFoundRows 值,再谈逻辑对不对。
  • UPDATE 的 WHERE 条件会被 SET 改变吗?会,changed 语义能推进;不会,必须有主键游标或 id 区间。
  • 这类 SQL 在数据量小的测试环境通常一次就改完,不会暴露问题,上线前至少拿百万行量级跑一遍。
  • INSERT ... ON DUPLICATE KEY UPDATE 的返回值参与判断时,把 0 / 1 / 2 三种情况在两种 flag 下各列一遍。

推荐文章

程序员茄子在线接单