openlang

20 个 goroutine 下每日限额被突破,但余额守卫没问题。是一个 bug 还是两个?

Go + pgx pool + Postgres,默认 Read Committed,每次取款一个事务。余额 1000,daily_limit 50,20 个 goroutine 各取 10。我预期 5 次成功,结果是 7、8 或 9 次。今天的 sum(amount) 最后是 70 到 90。MaxConns=1 时正好是 5。

余额的保护看起来没问题。余额 15 的时候,一个成功,其余的 UPDATE 只返回零行,不会阻塞也不会报错。

INSERT INTO withdrawals (account_id, amount)
SELECT $1, $2
WHERE (SELECT coalesce(sum(amount), 0)
         FROM withdrawals
        WHERE account_id = $1 AND day = current_date) + $2
      <= (SELECT daily_limit FROM accounts WHERE id = $1)
UPDATE accounts
SET balance = balance - $2
WHERE id = $1 AND balance >= $2

Postgres 对 INSERT ... SELECT 的 WHERE 会像对 UPDATE 那样重新检查吗?还是说这是两回事?日志里没看到异常,-race 也是干净的。

我在看的隔离文档在这里 https://www.postgresql.org/docs/current/transaction-iso.html

从英语翻译查看原文

后续讨论 3

顺着彼此的依据读下去
  1. 固定链接

    据我理解,这是两回事。在 Read Committed 下,UPDATE 碰到另一个事务刚改过的行时,会等待那个事务,然后针对新的行版本重新计算 WHERE,所以你的余额守卫是有效的。INSERT ... SELECT 没有这样的行可等,因为它只读语句开始时取的快照,所以二十个并发的都能看到同一个总和,全部通过。先加行锁,用 SELECT ... FROM accounts WHERE id = $1 FOR UPDATE,或者在 INSERT 之前先执行余额的 UPDATE,应该就能按账户把它们串行化,详见 https://www.postgresql.org/docs/current/explicit-locking.html.

    从英语翻译查看原文
  2. 固定链接

    和我看到的现象一致。关于这个修复我补一个提醒,免得有人抄错,锁必须是独立的一条语句,放在 INSERT 之前。在 Read Committed 下每条语句都会取新的快照。所以第一步先 SELECT ... FROM accounts WHERE id = $1 FOR UPDATE,第二步再 INSERT,INSERT 的快照就是在拿到锁之后取的,这样它能看到上一个事务已提交的取款记录。

    如果用 CTE 把锁和 INSERT 合进同一条语句,就又变回等待之前取的同一个快照了。我估计超额还是会出现,不过这个写法我还没跑过。

    先更新余额的 UPDATE 这个思路也行,但那只是匹配到的行上的行锁带来的副作用。如果 UPDATE 什么都没匹配到,就什么都没锁。我还是更倾向显式加锁,因为它能直接表明用途。

    “EvalPlanQual”重新检查的说明在这里的 Read Committed 一节 https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED

    我会加上锁重新跑一遍 20 个 goroutine,看看是不是正好 5。

    从英语翻译查看原文
  3. 固定链接

    补充一点我之前的回答,你问这是一个 bug 还是两个,答案是两个,而 MaxConns=1 的结果就是线索,因为只有一个连接时事务被迫一个接一个地执行,每个 INSERT 都能看到前一个的提交,所以正好是 5。连接池大小并没有掩盖 Go 这边的问题,它只是掩盖了数据库里缺失的串行化。

    如果你不想每次取款都加行锁,还有两条路,在考虑更花哨的办法之前我会先看这两条。第一条是在 accounts 行上维护一个累计值,比如一个按日期重置的 spent_today 列,然后把限额检查和余额检查放进同一条 UPDATE,在 WHERE 里写 spent_today + $2 <= daily_limit。这样两个守卫都落在同一行上,你已经信得过的那种重新检查就会起作用。第二条是 Serializable 隔离,应用在遇到 SQLSTATE 40001 时重试。这样能行,但之后你就得把重试循环写好。

    不管用哪种,我都会在同样的二十个 goroutine 下测一遍再放心。

    从英语翻译查看原文

同一个问题的其他角度

  1. rows.Next 返回了 false,错误却不知道去哪了

    一个基于 database/sql 的小工具返回了半个列表。没有报错。代码如下。 context 带了 deadline。取数据到一半 deadline 到了,rows.Next() 返回 false,循环结束,函数把已经收集到的东西加上 nil 一…

    回复 3最近活动 sqlgo
  2. 部分索引与 WHERE 子句中的参数

    我在给课程项目用 Go 写一个小型任务执行器。环境是通过 database/sql 使用 pgx v5,本地 Postgres 16,还有一张约 600 万行的 jobs 表。几乎全是 done,只有几百行是 queued。我给等待中的行建了个部分索…

    回复 10最近活动 sqlgo
  3. 总计打印出来是数字粘在一起,不是求和

    每日的行都正常(12500、3400、900 won),但 TOTAL 打印出来是 0125003400900 KRW。为什么多了一个 0? tsc 能通过,Postgres 16 跑在 docker 里。reduce 还是一直以来那个写法……

    回复 10最近活动 typescriptsql