Go 25:database/sql 如何管理连接与结果集
上篇把出站 HTTP 的请求期限与任务取消分开。任务服务仍有一处更大的缺口:已接受任务只在进程内的 map 中,进程一停,按 ID 查询就失去依据。本篇在同一工程中添加真实 PostgreSQL 入口,从最早的一步——先保存待领取任务——检查 database/sql 管理连接和结果集的方式。完整工程及原始实验、SHA-256、独立解包运行说明与本篇一起交付。
sql.Open 返回了什么
先把一条提交的文字 Go 记录为 task[0]。若把 sql.Open("postgres", dsn) 没有报错理解成“数据库可用”,在服务真正需要提交时才会撞上连接错误。本版 Go 1.27.1 的 database/sql.Open 文档说 Open 可能只校验参数,不立刻建立连接,返回的 *sql.DB 是维护连接池的句柄,并非某一条固定连接。这里固定 PostgreSQL 16.15 与驱动 github.com/lib/pq v1.12.3;go.mod、go.sum 锁住实际模块版本与摘要,驱动使用 postgres 名称。没有把 PostgreSQL 服务版本与驱动模块版本误写成 Go 标准库版本。
可选 SQL 版任务 HTTP 服务在启动时调用 newSQLTaskRepository(ctx, db):先执行 PingContext,再建 go_tasks 表。试验用不存在的本机服务地址调用同一驱动,sql.Open 正常返回句柄,PingContext 报连接错误;后续真实 SQL 因而不必等到第一位用户提交时才暴露这个缺口。反过来,启动时能 Ping 并不保证此后的每次请求都能成功:数据库会断线,连接池也可能被占满。当前 HTTP 路径在 SQL 插入报错时返回 503,不将任务放入内存执行器,因此没有“客户端收到拒绝、worker 却已经启动”的同进程假成功。
实验在新建的本地数据目录运行 PostgreSQL 16.15,仅监听 127.0.0.1。先提交 POST /tasks 收到 202,再用 SQLTaskRepository.Lookup 按同一任务 ID 查到 input_text=Go, status=QUEUED。实现顺序是先 INSERT,成功才向既有内存队列提交,worker 不能在数据库插入之前从队列领取此任务。这是当前版本接受路径的约束,不是全系统持久化保证:若提交回包在网络上丢失,调用方不能仅凭未收到 202 判断 SQL 没有插入,幂等 ID/未知提交的处理还没做。
查询结果既是数据也是资源
Lookup 使用 QueryContext(ctx, ... WHERE id=$1),数据库参数与输入值分开;返回的 *sql.Rows 由本函数关闭。读出 id、原文、状态和计数字段之后,缺失 ID 返回可判断的 ErrSQLTaskNotFound;扫描报错则保留具体错误,而不伪装成“找不到任务”。这两类结果对后续 HTTP/SQL 边界不同:404 可以表示数据确实不存在,扫描失败代表服务没有成功读取该记录。
一个受控反例让真实表中的一行返回 SQL NULL,再把它扫描到 Go int:Rows.Scan 报错。测试明确关闭 Rows,随后同一 *sql.DB 的 PingContext 仍成功;“扫描出错”不表示可以跳过关闭查询结果。另一组对照给池设 SetMaxOpenConns(1),从 PostgreSQL 取 generate_series(1,1000),只读第一行而不关闭 Rows。此时第二个操作在测试给出的期限内因等待唯一池槽位而返回 context.DeadlineExceeded;关闭 Rows 后同一池再次查询成功。它证明本轮资源归还路径,不证明所有驱动和负载下连接数永远固定。Rows.Close 与读取时还要检查 rows.Err(),不能把空结果、坏行和中断读取全部合并成同一种错误。
database/sql 的 DB 可以跨调用复用,查询结果则有较短的所有权期限。sqlTaskRepository 不为每个查询重新 sql.Open,也不会把 *Rows 从函数里不受控地传出去。单个连接当前能否复用,是池与驱动在这轮实际运行中的表现;代码依赖的是关闭结果集的公开契约,不靠猜测 PostgreSQL 后端连接缓存的私有实现。
已接受不等于完整持久化
服务处理完 Go 后,HTTP GET /tasks/task[0] 在内存里返回 SUCCEEDED;同一时刻 SQL 仍是 QUEUED。这个差异是本章刻意保留的边界,不能把表中那条已提交记录冒充成功结果,或让读者误以为当前服务已经支持进程重启恢复。本篇只证明真实 PostgreSQL 上的入队写入、按 ID 读出、连接失败、扫描失败和结果集归还;worker 的 RUNNING、结果及终态尚未同步进 SQL。下一章必须把领取与完成推进为事务/条件更新,才能讨论两个 worker 竞争、结果一致性和数据库成为状态来源。数据库写入成功也仍不等于外部 HTTP 下游已经处理完成。
本轮 TestSQL* 在带实际 DSN 的环境跑了 15 次,包含服务 POST/GET 与数据库按 ID 读出、关闭数据库时 POST 503/GET 404、失败连接/坏扫描、独占池槽位与关闭恢复;两包完整 Go 测试、go test -race ./...、旧 CLI 的正常批次与零期限失败也有实际命令、原始 stdout/stderr 和退出码,见 writing-plans/go-language/evidence/25/。如果不设置 GO_LANGUAGE_SQL_DSN,集成测试会 Skip;这种普通 go test ./... 不能写成真实 SQL 已验收。固定工具链仍是 Go 1.27.1/linux-amd64,driver 固定在 go.mod/go.sum。本机 race 无报告只限这些输入和路径;其他平台、异常重启、真实任务结果 SQL 回写、全站构建与发布均 NOT_RUN。
练习:连接地址错了,sql.Open 成功能否作为健康检查?答案:不能,至少要像本章一样真正执行 PingContext,但一次成功也不能担保以后不断线。再把查询函数的 defer rows.Close() 删除:扫描到第一行后就返回,为什么本轮并发只有一个连接时另一条 SQL 会超时?结果集尚占池槽位;恢复关闭后查询可继续。如果 HTTP 已返回 202,能否因为 SQL 行还标 QUEUED 就告诉用户这次统计没执行?也不能:当前 SQL 还没有同步终态,需读既有服务的查询路径,或等后续章节完成真实状态迁移。
参考资料
database/sql.Open、DB.QueryContext、Rows.Close、Rows.Scan:Go 1.27.1 本地go doc命令和双流原始记录见writing-plans/go-language/evidence/25/doc-*。lib/pq:驱动 v1.12.3 的本地 module README、依赖下载与校验记录见writing-plans/go-language/research/25.md;PostgreSQL 16.15 的服务版本由本次真实 SQL 查询核对。






