TT Lab
开始
学习 学习路径 课程

不可撤销的变更

一个转圈图标背后藏着三份预算

在 TT Lab 中继续学习

目标

用 psql 的两个会话亲自测量锁等待、语句执行、重试次数这三种预算。区分接收 55P03 和 57014,用后端 PID 证明按事务范围设置的预算不会留在同一个连接上,并确认失败的事务不会自己关闭,然后写出预算表。

为什么重要

阅读中所说的三种预算,在界面上是区分不出来的。对用户来说,全都只是一个“转圈的图标”,于是他再按一次按钮。再按一次的请求,会在等待同一行的队伍里多加一个,让问题变得更大。要告诉客户什么时候可以再按,首先要知道在哪里等了多久,以及这次失败属于不是可以再次尝试的类型。

在代码里区分它们的方法,不是错误语句,而是 SQLSTATE。因为拿不到锁而中断是 55P03(lock_not_available),语句本身被取消是 57014(query_canceled),在失败的事务里又发送下一条命令是 25P02(in_failed_sql_transaction)。比较消息字符串的代码,一旦区域设置改变就会崩溃,但这五个字符不会变。

设置预算的位置也很重要。把 set_config 的第三个参数设为 true,它就只在那个事务中有效,无论提交还是回滚都会消失。如果设在会话上或者数据库默认值上,之后借走这个连接的人的请求,也会在很短的时间内被切断。这种事故的原因不在这段代码里,所以极难查找。

预计 80 分钟。请在到期前点击“+时间”延长会话。会话结束后 /root 下的文件会全部消失。

环境

PostgreSQL 16 已经在 Pod 内运行。连接用 psql -X -U lab -d labdb,主机是 127.0.0.1(export PGHOST=127.0.0.1)。不需要互联网或额外安装。

本实验只在 schema budget 内操作。产出物都放在 /root/budget 下。blue 客户的 9 号订单是测试用的行,所以每次你运行、每次评分时,revision 都会升高——不要背下那个值本身,需要时重新读取。评分器会真的重新运行你做的脚本来判定,所以脚本无论运行多少次,都必须输出同样形状的结果。

等待锁,不用固定 sleep 来处理。第 1 步的任务,是在 pg_stat_activity 中确认持锁方确实已经持有锁之后再往下走,之后的所有步骤都使用这个脚本。

步骤

  1. 用 /root/budget/01-schema.sql 建立 schema budget 和表 budget.orders——有 tenant、id、qty、state、revision 五列,主键是 (tenant, id),放入 blue 客户的 1 号(7 个 pending revision 3)、2 号(4 个 pending revision 1)、9 号(6 个 pending revision 0)三行。9 号是整个实验中一直用于测试的行,所以版本会不断升高。然后创建 /root/budget/holder.sh——用 bash holder.sh <초> <주문번호>(占位符依次为秒数和订单编号)调用时,后台的 psql 会话用 select ... for update 持有该行,用 pg_sleep 撑住之后提交。该会话的 application_name 必须是 lockholder,脚本不能用固定 sleep,而要轮询 pg_stat_activity,确认该会话持有锁并处于休眠之后,才能打印 granted=yes 并结束。最后运行 bash holder.sh 3 9,并在 /root/budget/01-holder.txt 中留下 rows、probe_id、granted、holder_seconds 四行。
  2. 创建 /root/budget/attempt.sh——用 bash attempt.sh <잠금ms> <문장ms> <주문번호>(占位符依次为锁毫秒数、语句毫秒数和订单编号)调用时,在一个事务内,把 set_config 的第三个参数设为 true,设置 lock_timeout 和 statement_timeout,把 blue 客户的那个订单行用 revision = revision + 1 更新,然后提交。无论成功还是失败,退出码都是 0,标准输出只打印 sqlstate、rows、elapsed_ms 三行。没有错误时,sqlstate 是 00000。然后用 bash holder.sh 5 9 持有 9 号行,运行 bash attempt.sh 200 1500 9,把结果以 sqlstate、lock_timeout_ms、statement_timeout_ms、waited_ms、rows 五行留在 /root/budget/02-lockwait.txt 中。
  3. 这次把两个预算反过来设置。用 bash holder.sh 5 9 持有 9 号行,运行 bash attempt.sh 400 150 9——锁预算 400ms,语句预算 150ms。把结果以 inverted_lock_ms、inverted_statement_ms、inverted_sqlstate、normal_sqlstate、first_budget 五行留在 /root/budget/03-stmt.txt 中。normal_sqlstate 是第 2 步得到的代码,first_budget 写这次先中断的设置名称(lock_timeout 或 statement_timeout)。
  4. 创建 /root/budget/leak.sh——只启动一次 psql,在同一个连接内询问四次。在事务之外一次(before),在事务内设置 set_config('lock_timeout','250ms',true) 和 set_config('statement_timeout','1200ms',true) 之后一次(inside),提交之后一次(after_commit),再次设置后回滚,然后一次(after_rollback)。四行都按 키=값/백엔드PID(占位符依次为键、值和后端 PID)的形式打印。看了运行结果,在 /root/budget/04-noleak.txt 中留下 inside、after_commit、after_rollback、pid_same、db_role_settings 五行。db_role_settings 是在 pg_db_role_setting 中保存了 lock_timeout 或 statement_timeout 的条目的数量。
  5. 创建 /root/budget/retry.sh——用 bash retry.sh <시도횟수> <잠금ms> <문장ms> <주문번호>(占位符依次为尝试次数、锁毫秒数、语句毫秒数和订单编号)调用时,它调用 attempt.sh,只有在 sqlstate 为 55P03 时、而且还有剩余的尝试次数时,才稍作休息后再次调用。尝试次数包含首次调用——为 3 时,是首次一次和重试两次。其他代码原样传递,不再调用。如果尝试次数不是 1 到 16 的整数,就一次也不调用 attempt.sh,打印 tries=0、final_sqlstate=22023、outcome=invalid。在正常路径上打印 tries、final_sqlstate、outcome 三行,outcome 在 final_sqlstate 为 00000 时是 applied,否则是 gaveup。在没有人持有锁时运行一次,在 bash holder.sh 6 9 之后用 3 200 1500 9 运行一次,在同一个持锁方还在时用 3 400 150 9 运行一次,把结果以 free_tries、free_outcome、locked_tries、locked_outcome、locked_sqlstate、cancelled_tries、cancelled_sqlstate 七行留在 /root/budget/05-retry.txt 中。
  6. 创建 /root/budget/idle.sh——用 bash idle.sh <주문번호>(占位符为订单编号)调用时,在一个连接内按事务范围设置两个预算,并尝试用 select ... for update 持有该行,失败之后,在回滚之前再发送任意一条 SELECT,然后回滚,回滚后再发送 SELECT,读取两个预算的当前值,比较最初和最后的后端 PID。输出是 sqlstate、aborted_sqlstate、after_rollback、lock_timeout_after、statement_timeout_after、pid_same 六行。用 bash holder.sh 5 9 持有之后,运行 bash idle.sh 9,把这六行原样留在 /root/budget/06-idle.txt 中。
  7. 创建 /root/budget/apply.sh——用 bash apply.sh <잠금ms> <문장ms> <주문번호> <승인당시revision>(占位符依次为锁毫秒数、语句毫秒数、订单编号和批准时的 revision)调用时,在一个事务内设置两个预算,只有当批准时的 revision 和 pending 状态都相符时,才把该行的 revision 加 1。输出是 sqlstate、rows、verdict 三行,verdict 如下——没有错误且修改了 1 行是 applied,没有错误但是 0 行是 stale,55P03 是 locked,57014 是 cancelled,其他是 error。读取 9 号行当前的 revision,用该值运行一次(applied),用同一个值再运行一次(stale),在 bash holder.sh 5 9 之后,用当前的 revision 以 200 1500 运行一次(locked)、以 400 150 运行一次(cancelled),把结果以 fresh_verdict、stale_verdict、locked_verdict、locked_sqlstate、cancelled_verdict、cancelled_sqlstate 六行留在 /root/budget/07-verdict.txt 中。
  8. 最后,把本实验确定的预算用十行写在 /root/budget/08-budget.txt 中——lock_timeout_ms 是 200,statement_timeout_ms 是 1500,attempts 是 3,retry_pause_ms 是 200(从第 2 步起就在用的值)。worst_case_db_wait_ms 是尝试次数乘以锁预算,worst_case_elapsed_ms 是在此基础上加上休息时间乘以(尝试次数减 1)。measured_locked_ms 和 measured_sqlstate 是用 bash holder.sh 5 9 持有之后,再运行一次 bash attempt.sh 200 1500 9 得到的实际值。retry_on 里写要重试的 SQLSTATE,propagate 里写原样传递的 SQLSTATE。

参考

制造出其他负责人持有该行的状态

用 /root/budget/01-schema.sql 建立 schema budget 和表 budget.orders——有 tenant、id、qty、state、revision 五列,主键是 (tenant, id),放入 blue 客户的 1 号(7 个 pending revision 3)、2 号(4 个 pending revision 1)、9 号(6 个 pending revision 0)三行。9 号是整个实验中一直用于测试的行,所以版本会不断升高。然后创建 /root/budget/holder.sh——用 bash holder.sh <초> <주문번호>(占位符依次为秒数和订单编号)调用时,后台的 psql 会话用 select ... for update 持有该行,用 pg_sleep 撑住之后提交。该会话的 application_name 必须是 lockholder,脚本不能用固定 sleep,而要轮询 pg_stat_activity,确认该会话持有锁并处于休眠之后,才能打印 granted=yes 并结束。最后运行 bash holder.sh 3 9,并在 /root/budget/01-holder.txt 中留下 rows、probe_id、granted、holder_seconds 四行。

application_name 在调用 psql 时,前面加上 PGAPPNAME 就能指定。锁是否已被持有,可以通过 pg_stat_activity 的 wait_event 是否变成了 PgSleep 来判断——到了那个时刻,就说明 FOR UPDATE 已经结束了。用固定 sleep 的话,在机器繁忙时,锁还没被持有,下一步就开始了,结果会时好时坏。

用代码接收锁预算切断了请求的证据

创建 /root/budget/attempt.sh——用 bash attempt.sh <잠금ms> <문장ms> <주문번호>(占位符依次为锁毫秒数、语句毫秒数和订单编号)调用时,在一个事务内,把 set_config 的第三个参数设为 true,设置 lock_timeout 和 statement_timeout,把 blue 客户的那个订单行用 revision = revision + 1 更新,然后提交。无论成功还是失败,退出码都是 0,标准输出只打印 sqlstate、rows、elapsed_ms 三行。没有错误时,sqlstate 是 00000。然后用 bash holder.sh 5 9 持有 9 号行,运行 bash attempt.sh 200 1500 9,把结果以 sqlstate、lock_timeout_ms、statement_timeout_ms、waited_ms、rows 五行留在 /root/budget/02-lockwait.txt 中。

要把 SQLSTATE 当字符串比较,给 psql 加上 -v VERBOSITY=verbose,错误行里就会同时出现代码。用 grep 去匹配错误消息中的中文或英文句子的方式,区域设置一变就会崩溃。waited_ms 把 attempt.sh 打印的 elapsed_ms 原样搬过来就行。

语句预算比锁预算短时,先切断的是什么

这次把两个预算反过来设置。用 bash holder.sh 5 9 持有 9 号行,运行 bash attempt.sh 400 150 9——锁预算 400ms,语句预算 150ms。把结果以 inverted_lock_ms、inverted_statement_ms、inverted_sqlstate、normal_sqlstate、first_budget 五行留在 /root/budget/03-stmt.txt 中。normal_sqlstate 是第 2 步得到的代码,first_budget 写这次先中断的设置名称(lock_timeout 或 statement_timeout)。

PostgreSQL 文档明确说过——statement_timeout 不为 0 时,把 lock_timeout 设成与它相同或更大是没有意义的。因为语句预算总是先触发。两个代码不同这一事实很重要。一个是等待后没拿到,一个是语句本身被取消,所以接下来要做的事不同。

用同一个 PID 证明预算不会留在借来的连接上

创建 /root/budget/leak.sh——只启动一次 psql,在同一个连接内询问四次。在事务之外一次(before),在事务内设置 set_config('lock_timeout','250ms',true) 和 set_config('statement_timeout','1200ms',true) 之后一次(inside),提交之后一次(after_commit),再次设置后回滚,然后一次(after_rollback)。四行都按 키=값/백엔드PID(占位符依次为键、值和后端 PID)的形式打印。看了运行结果,在 /root/budget/04-noleak.txt 中留下 inside、after_commit、after_rollback、pid_same、db_role_settings 五行。db_role_settings 是在 pg_db_role_setting 中保存了 lock_timeout 或 statement_timeout 的条目的数量。

psql 调用四次就是四个连接,什么也证明不了——所以才让它同时打印 PID。四行的 PID 相同,才是同一个连接。把它作为默认值设在数据库或角色上的方法(alter database ... set)在本实验中是禁止的。那样会让别人的请求也继承这个预算。

可以重试的失败只有一种

创建 /root/budget/retry.sh——用 bash retry.sh <시도횟수> <잠금ms> <문장ms> <주문번호>(占位符依次为尝试次数、锁毫秒数、语句毫秒数和订单编号)调用时,它调用 attempt.sh,只有在 sqlstate 为 55P03 时、而且还有剩余的尝试次数时,才稍作休息后再次调用。尝试次数包含首次调用——为 3 时,是首次一次和重试两次。其他代码原样传递,不再调用。如果尝试次数不是 1 到 16 的整数,就一次也不调用 attempt.sh,打印 tries=0、final_sqlstate=22023、outcome=invalid。在正常路径上打印 tries、final_sqlstate、outcome 三行,outcome 在 final_sqlstate 为 00000 时是 applied,否则是 gaveup。在没有人持有锁时运行一次,在 bash holder.sh 6 9 之后用 3 200 1500 9 运行一次,在同一个持锁方还在时用 3 400 150 9 运行一次,把结果以 free_tries、free_outcome、locked_tries、locked_outcome、locked_sqlstate、cancelled_tries、cancelled_sqlstate 七行留在 /root/budget/05-retry.txt 中。

重试是次数契约,而不是时间契约。不无限期地等到锁释放,而是只尝试规定的次数就放弃,原因是堆积等待中的请求,本身就是下一个事故。如果重试语句取消(57014),同一条语句会再占用同样长的时间。

失败的事务不会自己关闭

创建 /root/budget/idle.sh——用 bash idle.sh <주문번호>(占位符为订单编号)调用时,在一个连接内按事务范围设置两个预算,并尝试用 select ... for update 持有该行,失败之后,在回滚之前再发送任意一条 SELECT,然后回滚,回滚后再发送 SELECT,读取两个预算的当前值,比较最初和最后的后端 PID。输出是 sqlstate、aborted_sqlstate、after_rollback、lock_timeout_after、statement_timeout_after、pid_same 六行。用 bash holder.sh 5 9 持有之后,运行 bash idle.sh 9,把这六行原样留在 /root/budget/06-idle.txt 中。

在出错的事务里发送下一条命令,服务器会拒绝——这个拒绝也有它专属的 SQLSTATE。如果失败的请求让连接保持那个状态就回到了池里,下一个人无论发送什么,都会得到同样的拒绝。请同时确认,预算是否会随回滚而消失。如果给 psql 加上 ON_ERROR_STOP,它会在第一个错误处退出,之后的情况就看不到了。

把等待之后失败的,与批准过期而被拒绝的区分开

创建 /root/budget/apply.sh——用 bash apply.sh <잠금ms> <문장ms> <주문번호> <승인당시revision>(占位符依次为锁毫秒数、语句毫秒数、订单编号和批准时的 revision)调用时,在一个事务内设置两个预算,只有当批准时的 revision 和 pending 状态都相符时,才把该行的 revision 加 1。输出是 sqlstate、rows、verdict 三行,verdict 如下——没有错误且修改了 1 行是 applied,没有错误但是 0 行是 stale,55P03 是 locked,57014 是 cancelled,其他是 error。读取 9 号行当前的 revision,用该值运行一次(applied),用同一个值再运行一次(stale),在 bash holder.sh 5 9 之后,用当前的 revision 以 200 1500 运行一次(locked)、以 400 150 运行一次(cancelled),把结果以 fresh_verdict、stale_verdict、locked_verdict、locked_sqlstate、cancelled_verdict、cancelled_sqlstate 六行留在 /root/budget/07-verdict.txt 中。

四种结果接下来的行动全都不同。locked 可以稍后再试,cancelled 如果再发送同一条语句,会再占用同样长的时间,stale 无论重发多少次,永远是 0 行,必须重新获得批准。把重试条件限定为 sqlstate 一个值,原因就在这里。

用表格写出在哪里等多久

最后,把本实验确定的预算用十行写在 /root/budget/08-budget.txt 中——lock_timeout_ms 是 200,statement_timeout_ms 是 1500,attempts 是 3,retry_pause_ms 是 200(从第 2 步起就在用的值)。worst_case_db_wait_ms 是尝试次数乘以锁预算,worst_case_elapsed_ms 是在此基础上加上休息时间乘以(尝试次数减 1)。measured_locked_ms 和 measured_sqlstate 是用 bash holder.sh 5 9 持有之后,再运行一次 bash attempt.sh 200 1500 9 得到的实际值。retry_on 里写要重试的 SQLSTATE,propagate 里写原样传递的 SQLSTATE。

这张表就是回答客户的句子——什么时候可以再按、最坏要多久。这里写的数字,只覆盖数据库等待和重试。连接建立、多条 SQL、响应传输都在这张表之外,所以不要说它等同于整个 API 的时间预算。