上篇先把 QUEUED 写进真实 PostgreSQL,但 worker 完成时 SQL 还是 QUEUED。若服务把数据库改成查询状态来源,却不改变领取和完成路径,用户会一直看见一项“等待执行”的任务。本篇继续修改同一个任务服务:领取必须先在表中赢得一个条件更新,终态和结果一起提交。完整累计工程、摘要及独立解包步骤包含本次正反证据。

领取的判断要落在数据库提交点

表中一行是 id=task[shared], status=QUEUED。两个 worker 同时先查询、都看见 QUEUED、再各自修改内存标志,并不能阻止它们重复领取。sqlTaskRepository.Claim 开始一个 database/sql 事务,在事务里执行 UPDATE go_tasks SET status='RUNNING' WHERE id=$1 AND status='QUEUED'。检查 RowsAffected():只有影响一行的调用才提交并进入分析器;另一个没有命中条件的调用回滚并报告未领取。这样做不把 Go 的互斥锁误当成跨数据库连接的领取仲裁。

本次用 PostgreSQL 16.15 的隔离实例,把池上限设成 2 条连接,同一个闸门同时放行两个 caller 领取同一个 ID。30 次复跑中每轮都恰好一次 Claim=true,另一方 false。本机数据库的 SHOW default_transaction_isolation 返回 read committed;PostgreSQL 16 官方文档在这一隔离级别下说明:并发 UPDATE 等待先前更新者完成后,会对更新后的行重新检查 WHERE 条件。这项版本契约和本轮实测支撑当前结论,不能推出其他数据库或隔离级别也按相同细节执行。事务 Commit 报错时,调用方也不能只凭错误断定数据库没有提交;未知结果要留给后续查询/恢复流程处理,不能再放一个 worker 盲目重试分析。

第 25 章的 HTTP 可选 SQL 构造入口仍先 INSERT 原文和 QUEUED,再把任务送进原有执行器。SQL 模式的 worker 现在通过 Claim 取得数据库里的资格;原内存队列只负责本进程唤醒,不再负责裁定谁已领取。SQL 模式的 GET /tasks/{id} 也读数据库的记录,而不是把过期的内存状态展示给客户端;数据库不可用时是 503,不是 404。这里仍是单实例队列,多实例租约与长时间失联后的重新领取不是本篇结论。

终态和结果不能分两次宣布

文本统计结束后,Finish 在一个事务中以 WHERE id=$1 AND status='RUNNING' 更新状态及 words/bytes/runes。成功且没收到任务取消请求时写 SUCCEEDED 和这组三个计数;分析失败时写 FAILED 且三个计数为零;运行中请求取消已经登记时,等分析器实际返回才写 CANCELED/零计数。第二次对已终态任务执行 Finish 条件不匹配,返回 ErrSQLTaskNotRunning;它与“ID 不存在”的查找错误分开,不会让前一次终态反转。此处的“结果一致”指同一 SQL 行的状态和三个统计字段,不包括外部 HTTP 下游已完成处理。

回滚反例用真实数据库另起事务:先把 task[shared] 从 RUNNING 改成 SUCCEEDED/words=99,随后在同一事务执行 SELECT 1/0 故意触发 PostgreSQL 错误,并调用 Rollback。之后查询仍是 RUNNING/words=0。再由生产 Finish 提交一次 SUCCEEDED/words=2、bytes=9、runes=9;第二次提交被拒绝。这个独立反例证明此处的事务回滚和条件更新,却不是对生产 Finish 所有失败点的自动故障注入。提交应答丢失、数据库断线导致结果不明的场景尚未验证恢复策略。

任务分析器也可能算出局部数字才返回错误。注入分析器返回 Words=99 和错误时,本次 SQL 终态是 FAILED,三个计数字段仍是零,不会把半份业务结果与失败状态混在一起。POST /tasks 先返回 202;之后另发 GET 才能判断任务结果。数据库提交成功只表明本表状态已改变,不证明合成下游处理成功,更不保证外部副作用只发生一次。

请求取消和退出确认仍是两件事

SQL 模式的取消 API 用条件更新保护状态:排队项可直接变 CANCELED,测试断言它没有进入第二次分析;运行中只登记 cancel_requested=true,分析器被闸门卡住时 GET 仍是 RUNNING。释放闸门并等 worker 完成,Finish 才提交 CANCELED。独立的 SQL Shutdown 测试向工作者发出取消后也保持 RUNNING,直到故意不合作的分析器真正退出,随后查询数据库才看到 CANCELED。不能因为取消 API 回了 202 或 context.Done() 已关闭,就提前报道业务工作完成。数据库不可用时 GET/取消可以失败,不能把 503 折算成不存在或已取消。

本次固定 Go 1.27.1/linux-amd64、github.com/lib/pq v1.12.3 与真实 PostgreSQL 16.15;本版 BeginTx、Commit、Rollback、RowsAffected 的一手文档和隔离级别查询、30 次竞争/取消/关闭受控复跑、完整两包测试、本机 race、旧 CLI 正反均按实际命令、双流、退出码保存在 writing-plans/go-language/evidence/26/。go test -race 没有报告只限执行路径。没有 DSN 时 SQL 测试会 Skip,不可拿跳过后的全包测试冒充真实数据库验收。源码镜像固定 SHA 862c888e612ac346c7c4d99c9392bdfd265f33b0 沿用基线,没有据源码私有实现解释这次 PostgreSQL 行锁顺序,官方 Gitiles 交叉核验仍 NOT_RUN。

练习:若取消在领取 UPDATE 之前发生,第二个 worker 能不能从 SQL 里成功领到已取消项?答案:不能,排队项状态已不再是 QUEUED,条件更新影响零行。若在运行中请求取消时统计函数无视 context,为什么 SQL 不能立即改成 CANCELED?此时分析器仍可能修改计算状态;应先记录请求、等它返回后通过同一终态提交点关闭任务。若 Commit 调用返回错误,能否直接再次执行有外部副作用的分析器?不能,先识别数据库中的状态和结果;提交结果未知与副作用次数未知不能混为一谈。

本章未实现崩溃后领取遗留 RUNNING、SQL 池饱和后的服务预算、多实例租约与去重、完整常驻 HTTP CLI、其他平台或发布,均 NOT_RUN。下一章将给连接池限额和 SQL 调用期限一个可控等待场景;再后才有足够依据讨论异常退出与恢复。

参考资料