Skip to content

两个 worker 都读到“还剩一张”,谁能向用户承诺? ​

先让 ModelService 控制器 跑起一个 CPU 模型,再考虑收费:team-a 最多用两格,已经占了一格。两个 API 实例同时受理新申请,各自查到 used=1。若它们都先回复“预留成功”,再各自把 used 写成 2,最后账面完全合法,平台却答应了三份。检验超卖不能只检查一个计数器有没有超过上限。

为什么要把“收到”与“承诺”分开 ​

submit 先用稳定的请求 ID 写 waiting;重复 ID 必须核对 tenant、cost、priority。客户端丢了 202 再次提交,不应再排一次队。下一步 admit 的承诺才是“团队预算和一个逻辑池都已为此请求预留”。Kubernetes 的 ResourceQuota 能拒绝超限对象,但不能替这个 API 实现按等待年龄排队、记录请求身份或生成待投递记录。单进程 Mutex 也挡不住另一个进程的数据库连接。

sequenceDiagram
participant A as API
participant W as Worker
participant D as MySQL
participant K as Kubernetes
A->>D: submit 写入 waiting
W->>D: admit 锁申请并条件更新两份用量
W->>D: 同一事务写 reserved 和 outbox
D-->>W: Commit 成功
W->>K: deliver 创建确定名字的 CR
K-->>W: 回读身份
W->>D: 记录 binding 并确认投递

完整源码:modeldock/admission/main.go · 第 34–73 行

go
func admit(db *sql.DB) error {
	tx, e := db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelReadCommitted})
	if e != nil {
		return e
	}
	defer tx.Rollback()
	if _, e = tx.Exec("UPDATE reservation SET state='expired' WHERE state='waiting' AND created < NOW()-INTERVAL 1 HOUR"); e != nil {
		return e
	}
	var id, t, node string
	var n int
	e = tx.QueryRow("SELECT r.id,r.tenant,r.cost FROM reservation r JOIN tenant_quota q ON q.tenant=r.tenant WHERE r.state='waiting' AND q.used+r.cost<=q.cap AND EXISTS(SELECT 1 FROM gpu_pool p WHERE p.used+r.cost<=p.cap) ORDER BY priority+TIMESTAMPDIFF(SECOND,created,NOW()) DIV 30 DESC,created,id LIMIT 1 FOR UPDATE SKIP LOCKED").Scan(&id, &t, &n)
	if errors.Is(e, sql.ErrNoRows) {
		return tx.Commit()
	}
	if e != nil {
		return e
	}
	e = tx.QueryRow("SELECT node FROM gpu_pool WHERE cap-used>=? ORDER BY cap-used,node LIMIT 1 FOR UPDATE", n).Scan(&node)
	if e != nil {
		return e
	}
	for _, q := range []struct{ sql, key string }{{"UPDATE tenant_quota SET used=used+? WHERE tenant=? AND used+?<=cap", t}, {"UPDATE gpu_pool SET used=used+? WHERE node=? AND used+?<=cap", node}} {
		r, e := tx.Exec(q.sql, n, q.key, n)
		if e != nil {
			return e
		}
		a, _ := r.RowsAffected()
		if a != 1 {
			return fmt.Errorf("capacity changed; retry")
		}
	}
	if _, e = tx.Exec("UPDATE reservation SET state='reserved',node=? WHERE id=?", node, id); e != nil {
		return e
	}
	if _, e = tx.Exec("INSERT INTO outbox(id) VALUES(?)", id); e != nil {
		return e
	}
	return tx.Commit()
}

顺着异常返回读这段代码:SKIP LOCKED 让 worker 不必等另一个 worker 已锁住的候选,但代价是不能承诺严格全局 FIFO。候选必须同时满足团队与单个资源池;两池各剩两格不能接一个要三格的请求。best fit 优先留下较大的完整余量;此处的 node 是账本里的逻辑池名,不是 kube-scheduler 的绑定结果。

拿到候选之后,还要看每次条件 UPDATE 的 RowsAffected。若另一份额度更新失败,不能只退后一笔或继续投递;defer Rollback 让尚未提交的整组写入一起失效。反过来,若数据库已提交但进程在调用 Kubernetes 之前崩溃,outbox 保留了待办。若外部 Create 已成功但回复丢失,恢复者要核对请求身份与 CR UID;重试的单位是投递,不是重新扣费。

用一份空账本验收 ​

先装 Go 与 MySQL(Mac 可用 brew install go mysql)。从实验根目录执行。测试提交 20 笔申请并启动 8 个 worker;它既检查成功行数,也检查是否漏记了待办:

完整源码:modeldock/admission/ledger_test.go · 第 35–49 行

go
				}
			}
		}()
	}
	wg.Wait()
	var used, rows, msgs int
	db.QueryRow("SELECT used FROM tenant_quota WHERE tenant='team-a'").Scan(&used)
	db.QueryRow("SELECT count(*) FROM reservation WHERE state='reserved'").Scan(&rows)
	db.QueryRow("SELECT count(*) FROM outbox").Scan(&msgs)
	if used != 2 || rows != used || msgs != used {
		t.Fatalf("used=%d reserved=%d outbox=%d", used, rows, msgs)
	}
	if e := submit(db, "job-0", "team-a", 2, 0); e == nil {
		t.Fatal("changed payload accepted")
	}

使用一次性 schema,以下脚本只清理本次创建的数据库;不要把测试 DSN 指向现有业务库。

sh
./scripts/mysql-lab.sh start
sock="$PWD/tmp/private-mysql/mysql.sock"
study_db="quota_study_$(date +%s)"
mysql --no-defaults -uroot -S "$sock" -e "CREATE DATABASE $study_db"
mysql --no-defaults -uroot -S "$sock" "$study_db" < modeldock/admission/schema.sql
(cd modeldock/admission && LAB_MYSQL_TEST_DSN="root@unix($sock)/$study_db" go test -count=1 -v -run TestConcurrentLedger .)
mysql --no-defaults -uroot -S "$sock" -e "DROP DATABASE $study_db"

测试断言已花额度=2、reserved 行数=2、outbox 行数=2,且改变旧申请参数会被拒绝。只看最终 used<=cap 不够:多收了钱却遗漏 outbox,账虽平,服务永远不会创建。生产要记录锁等待、死锁率、等待年龄和补偿积压;读查询用 context 设置截止时间。追问。 用 Redis 先减再写 SQL 能少一次数据库锁吗?它引入新的双写裂缝,除非重新定义权威账本及恢复协议。

从一行等待记录查到投递点 ​

在私有数据库的 mysql 客户端运行以下只读查询。若 reservation 已是 reserved、outbox.delivered 为 0,而查不到 CR,故障多半在投递流程;若 delivered 为 1 且绑定 UID 存在,转查 CR status 与 Pod Events。切勿因为 GET 超时就先把 used 改小。

sql
SELECT r.id,r.tenant,r.cost,r.state,r.node,o.delivered,b.uid,b.ever_ready
FROM reservation AS r
LEFT JOIN outbox AS o ON o.id=r.id
LEFT JOIN binding AS b ON b.id=r.id
ORDER BY r.created DESC LIMIT 10;
SELECT tenant,used,cap FROM tenant_quota;
SELECT node,used,cap FROM gpu_pool;

把错误放在事务的最后一行 ​

如果只测第一个 UPDATE 就失败,代码即使忘了回滚第二份账也可能通过。为此测试先让 team-b 提交一笔能正常入选的申请,再在一次性数据库内预放同 ID 的 outbox 行,故意使事务最后的 INSERT 碰到唯一键。两份用量和 reserved 状态此时已经在事务内改过;函数返回错误后,从另一次查询观察,它们必须全部恢复。删掉测试制造的冲突,再执行完整 admit,这次应该得到 reserved。

完整源码:modeldock/admission/ledger_test.go · 第 51–75 行

go
	// Inject a collision at the LAST insert, after both counters were updated.
	if err := submit(db, "rollback-probe", "team-b", 1, 100); err != nil {
		t.Fatal(err)
	}
	if _, err := db.Exec("INSERT INTO outbox(id) VALUES('rollback-probe')"); err != nil {
		t.Fatal(err)
	}
	if err := admit(db); err == nil {
		t.Fatal("expected duplicate outbox error")
	}
	var state string
	var tenantUsed, poolUsed int
	if err := db.QueryRow("SELECT state FROM reservation WHERE id='rollback-probe'").Scan(&state); err != nil {
		t.Fatal(err)
	}
	if err := db.QueryRow("SELECT used FROM tenant_quota WHERE tenant='team-b'").Scan(&tenantUsed); err != nil {
		t.Fatal(err)
	}
	if err := db.QueryRow("SELECT SUM(used) FROM gpu_pool").Scan(&poolUsed); err != nil {
		t.Fatal(err)
	}
	if state != "waiting" || tenantUsed != 0 || poolUsed != used {
		t.Fatalf("partial commit: state=%s tenant=%d pool=%d", state, tenantUsed, poolUsed)
	}
	if _, err := db.Exec("DELETE FROM outbox WHERE id='rollback-probe'"); err != nil {

这不是在模拟真实 GPU 失联,而是在验证一个具体的数据库原子性边界。追问:这种测试能证明投递只执行一次吗? 不能。它证明的是事务失败不留半份承诺;数据库以外的 Create 仍可能被尝试多次,需要外部对象身份与回读确认。