写完 HHY Web Runtime之后,我开始认真看数据库这一层。
HTTP handler 可以常驻,路由可以返回 JSON,但一个真正的 CMS 很快就会问出更具体的问题:保存文章时怎么同时写修订记录?导出几十万条记录时内存会不会一直涨?请求超时以后,数据库里的那次写入到底停了没有?
这些问题让我重新定义 HHY Database 的 1.0:它应该让应用能够判断一次操作发生了什么,也能够确定连接、事务和结果集最终由谁收尾。
DB 1.0.0 于 2026 年 9 月 7 日随 HHY 1.5.0 发布,数据库扩展独立版本化。 发行范围是 macOS arm64、Linux arm64/x86_64;Windows Runtime 包不包含 DB。新的事务回调、资源作用域和数据库 Stream 要求 HHY 1.5.0 或更新版本。Runtime 发布说明
下面复盘实际交付的实现与取舍。代码及能力说明以本文核对的仓库提交 3af2f67 为基线;性能数字来自已有验收记录,不是我在写文章时重新压测的结果。
从四个调用入口,走到有资源生命周期的访问层
0.2.0 的基础是 C 扩展通过 libpq 和 MySQL 客户端库执行 SQL,提供 ping、query、execute、transaction 四个入口。值参数绑定已经存在:PostgreSQL 使用 $1,MySQL 使用 ? 和原生 statement 绑定。
1.0 保留了这四个旧入口与字符串/null 的默认结果,同时补上结构化数据源、有界池、读写事务、保存点、可复用 statement、批量执行、增量游标、精确类型与取消清理。扩展更新记录
我比较满意的是,这次兼容没有靠“把所有东西藏起来”完成。旧脚本继续使用熟悉的结果,新代码显式选择 typed: true 或 Stream;远程连接使用带授权和 TLS 策略的配置,而不是让旧 URL 悄悄忽略不认识的参数。
1.0 的变化,在于连接、事务、游标和语句都有了能够结束、失效和回收的生命周期。 长期运行的服务,真正需要的是这些保证。
对照 Go、Python、PHP,我看的是职责如何安排
数据库驱动并不是语言本身。Go 的对标对象是 database/sql 加具体驱动;Python 是 DB-API 加驱动和池组件;PHP 是 PDO 加对应数据库驱动。把它们都写成“语言自带数据库连接池”,会掩盖真实差异。
| 对照对象 | 已有抽象与实现方式 | 对 HHY 的启发 |
|---|---|---|
Go database/sql | sql.DB 管理连接池,sql.Tx 绑定事务连接 | 池、连接、事务应有不同职责 |
| Python DB-API 2.0 | 约定 connection、cursor、参数绑定、事务和异常接口 | 稳定契约比统一一套语法更重要 |
| Python Psycopg Pool | 单独的池组件,用作用域管理借用与归还 | 正常退出与异常退出都要有资源收尾 |
| PHP PDO | 统一访问接口,提供预处理、事务及连接配置 | 保留驱动差异,避免让业务每次手动管理底层细节 |
Go 的池配置、事务入口,Python 的接口规范和 Psycopg 的作用域行为,都有各自明确的定义。Go 连接管理、Go 事务、PEP 249、Psycopg Pool
PDO 的持久连接允许连接被复用,但其生命周期与进程运行方式相关,不能直接等同于 Go 的并发连接池。连接复用还会带来会话状态清理问题。PDO 连接管理
我没有为 HHY 复制三种语言的 API。对标真正有意义的部分,是让应用在连接、事务、类型和错误这些常用能力上,得到清楚而可验证的行为。
连接池最重要的工作,是管理借出与归还
短连接的成本很好理解:一次小查询之外,还可能反复支付连接、认证与 TLS 握手的开销。复用连接能减少重复工作,但把连接放进数组,还不能叫一个可靠的池。
HHY 1.0 的池围绕下面这条生命周期组织:
等待配额 → 借出连接 → 独占使用 → 清理会话 → 归还
└→ 无法确认干净 → 销毁
连接归还之前,要处理未消费的结果集、未结束的事务,以及可能被业务改动的会话设置。上一条请求把时区或事务状态改了,下一条请求不应该悄悄继承。
池的身份也不能只看 host。数据库、用户、TLS 策略和凭据版本都影响一条连接是否可以复用。错误地把不同身份的连接放进同一个池,会把性能优化变成隔离问题。
我更看重“哪些连接不能归还”,而不是“怎样尽量少关闭连接”。 状态不确定时销毁连接,会多付一次建连成本,但能避免把异常带给下一位调用者。
这里还有一个值得单独说的实现:1.0 每个扩展最多处理 8 个并发协议请求,调用队列上限 64,每个数据源池的 max_open 默认 4、允许 1–32;每个扩展原生会话总数还受 64 的上限约束。
但单次 HHY 调用仍然是同步调用,Web 并发来自 Worker,也没有把数据库调用宣称为可任意传递给 HHY parallel 闭包的任务。原生协议并发、连接池容量和语言层并发,是三个需要分别说明的数字。 连接与并发契约
多 Worker 之后,连接预算要算乘法
我在 Web Runtime 里引入多 Worker 后,数据库池就不能只盯着一个进程的数字。
假设一种部署有 2 个应用实例,每个实例 4 个 Worker,每个 Worker 1 个数据库扩展实例,每个数据源池上限 8,访问 2 个数据源:
总连接预算 = 2 × 4 × 1 × 8 × 2 = 128
这是总预算示例,不是默认配置,也不一定全部落到同一台数据库;服务端容量还要按各池的实际目标分别汇总。滚动发布时新旧实例重叠,迁移任务或管理脚本额外运行,都要继续计入。
HHY 1.5.0 让各 Web Worker 在 fork 之后惰性启动自己的扩展,不共享父进程管道和数据库 socket。池满时有界等待,超时与队列满分别返回 DB_POOL_TIMEOUT 和 DB_QUEUE_FULL。MySQL 取消查询还会使用短暂的控制连接,服务端容量规划需要另外留出余量。并发与取消说明
还有一个很容易在小池里暴露的错误:事务已经占住唯一连接,事务内某段代码却又通过普通池执行查询。它会等待自己释放的连接。Go 官方文档也提醒,连接上限会带来类似锁或信号量的等待关系。Go 连接管理
因此事务内的调用应该使用已绑定连接,不能重新走一次普通池借用。
事务真正难的部分,是让业务逻辑留在同一连接上
CMS 发布文章通常不止一条 INSERT。它可能先读取文章版本,判断是否被别人编辑,再更新内容,写入修订记录,最后提交。
固定 SQL 列表适合预先知道所有操作的批处理;涉及读取与分支时,我需要的是一个受作用域约束的事务。
下面是三种生态里同一思想的简化控制流程,省略连接配置、业务 SQL 和完整错误处理;它们用来说明连接归属,不是可以直接部署的业务代码:
// Go: all transaction work goes through tx.
tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback()
if err := updateArticle(ctx, tx); err != nil { return err }
return tx.Commit()
# Psycopg Pool: the borrowed connection scopes the transaction.
with pool.connection() as conn:
update_article(conn)
# On normal exit, an open transaction commits; on exception, it rolls back.
// PDO: use the same connection and explicitly handle failure.
$pdo->beginTransaction();
try {
updateArticle($pdo);
$pdo->commit();
} catch (Throwable $error) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
Go 使用事务对象,Psycopg Pool 使用连接上下文,PDO 则提供显式事务方法。需要注意,catch 中尝试回滚不能证明一次失联的 COMMIT 没有成功。Go 事务、Psycopg Pool、PDO 事务
HHY 的实现多跨了一层进程边界:业务函数在宿主执行,连接在扩展进程里。1.5.0 的做法是把闭包留在宿主,由 with_transaction 把闭包内的数据库操作绑定到同一个事务资源。
下面是 1.0 已支持的 MySQL 事务写法。config 是应用预先建立的可信数据源配置,不来自 HTTP 请求;参数值只是示例:
import database
let before = config |> database.with_transaction { tx ->
let current = database.query(tx,
"SELECT balance FROM accounts WHERE id = ? FOR UPDATE", [1])
database.execute(tx,
"UPDATE accounts SET balance = balance - ? WHERE id = ?", [10, 1])
current.rows
}
这里的尾随闭包属于 HHY 的管道语法,不是 database.with_transaction(config) { ... }。回调正常结束提交,失败回滚;所有操作都使用 tx。示例只展示作用域,真实扣款还需要余额、账户状态及幂等检查。事务 API
资源 token 由扩展随机生成,绑定宿主请求作用域,不能跨请求、跨 Worker 传递,也不能序列化成持久 ID。事务和游标有初始操作 deadline 对应的租期;关闭后失效。
Web 请求与 embedded hhy_call 在成功或失败边界都会清理泄漏的数据库资源。这个兜底把回收放进请求执行过程,而不等待 GC。连接失联时则不能靠“尝试回滚”宣称数据库一定回到了原状态。宿主资源集成
保存点也要按数据库原有语义解释。它可以让外层事务回到中间位置,但不是独立提交的一层“嵌套事务”。
Stream 的价值,要从数据库读取那一刻开始成立
HHY 的 Flow 和 Stream 很适合表达导出管道。但这里最容易做出一个表面流式的实现:驱动先把全部结果读进内存,然后包装成 Stream,让业务逐条遍历。
这种实现只改变了消费接口,没有改变内存峰值。
0.2.0 的分析指出了全量缓冲的问题。1.0 仍保留有界的普通 query,但把大结果读取交给真正的增量 cursor 和惰性 Stream。普通查询最多返回 10,000 行,也可能更早触及字节预算,调用者必须检查 truncated。
1.0 的惰性数据库 Stream 让拉取需求从下游往上游传递:
HTTP 客户端可继续接收
→ 输出端请求下一批
→ HHY Stream 请求 fetch
→ 扩展从数据库读取有界批次
→ 转换并写出,再等待下一次需求
每批不仅限制行数,还要限制字节数、单字段大小与整体执行预算。一行文本可能比一千行小记录更大;Protocol v1 每行 1 MiB 的限制,也要求把 JSON 编码及二进制编码膨胀计入预算。
fetch 默认每批 100 行,字段上限 64 KiB,行或批次还受约 128 KiB 解码后、256 KiB 编码后的预算约束。这些限制必须同时成立;二进制十六进制编码会膨胀,也算在协议成本里。
我不会把这个边界解释成“任意数据都不可能瞬间超出内存预算”。README 明确说明,libpq 可能先接收到一个大行,再检查字段限制。流式内存与批次和最大驱动输入行有关,并不只由最终保留的行数决定。结果限制
提前 take、下游异常和请求关闭会触发资源清理;未完成或被污染的连接不能回到空闲池。事务里提前终止结果还可能使事务失效,需要回滚外层操作,因此回调返回前必须消费完其中的 Stream。
流式也有代价:慢消费者会长时间占住连接,甚至延长事务快照。deadline、导出并发预算和必要时的分任务处理,仍然是应用需要考虑的事情。Stream 生命周期
精确类型,比自动转成数字更重要
默认继续返回字符串或 null,保留了旧脚本的行为。但业务还需要知道一个字符串究竟是金额、主键、日期还是普通文本。
1.0 通过 typed: true 显式开启类型化结果。可表示的标量进入 HHY Int/Float/Bool;Decimal、超范围整数、时间、JSON 等保留带 type / value 的明确表示。类型契约
| 数据 | 1.0 保留的语义 |
|---|---|
| Decimal | 精确十进制值,不绕道 Float |
| 大整数主键 | 不先经过浮点表示;超出 HHY Int 范围时采用显式无损表示 |
| null | 与空字符串、零、没有查询到行分开 |
| 时间 | 区分有无时区,明确非法值与零日期行为 |
| Bytes | 有界十六进制 envelope,宿主转换为 BytesBuffer |
| 重复列名 | 保留位置访问,或明确报冲突,不能静默覆盖 |
比如 9007199254740993 这样的整数,一旦经过不能精确表示它的 binary64 浮点转换,再转回字符串也补不回精度。协议层“统一成 number”看似方便,实际上可能改变业务主键。
生成主键同样绑定产生它的那次写入。MySQL 的结果来自对应连接上的写入;PostgreSQL 可以通过 RETURNING 表达。不能写完以后,随便从池里借另一条连接去问“刚才生成了什么”。
影响行数也需要保留差异。MySQL 兼容保留 CLIENT_FOUND_ROWS,匹配了行但值没变化,和确实修改了行,不能含糊地都叫“更新成功了几条”。统一访问接口,不意味着抹平数据库语义。
比重试更重要的是,先知道自己是否有资格重试
我认为这次最值得保留的一个错误类别,是 DB_COMMIT_UNKNOWN。
假设数据库已经收到 COMMIT,也已经完成提交,但确认响应在返回途中丢了。应用看到超时,无法仅凭这个超时判断事务失败。
如果扩展自动重放整段写入,就可能把文章修订、库存扣减或业务流水再做一次。
1.0 的错误处理明确区分不同失败阶段;应用也需要据此选择处理方式:
| 失败位置 | 应用可以据此做什么 |
|---|---|
| 池等待阶段,尚未发送 SQL | 根据剩余时间和业务策略决定是否再次尝试 |
| 认证或 TLS 验证失败 | 修正配置,不做无意义的重复连接 |
| 约束冲突、死锁、序列化失败 | 按具体事务状态和业务语义处理 |
| 执行中失联 | 明确结果可能未知,不默认重放写入 |
| 提交时失联 | 返回提交未知,通过幂等标识或业务记录核对 |
扩展不会在错误后自动重放业务 SQL;提交遇到连接丢失或超时,返回 DB_COMMIT_UNKNOWN,不把它冒充成回滚成功。错误与事务语义
取消也类似。调用者收到“超时”只说明它不再等待,不足以证明数据库已经停止工作。Go 的 Context 提供了沿调用链传递取消的方式;HHY 1.5.0 则把 Runtime 取消与数据库扩展连接起来。Go 数据库取消
实现上,MySQL 用独立的短暂控制连接取消自身查询,libpq 17+ 使用异步取消,旧版 libpq 使用旧取消接口。取消失败时能够保证的是连接处置,不是数据库服务端工作立即结束;未知写入结果仍要由应用核对。
超时也进入资源租期。HHY 原生 Duration 会转换为毫秒参数,因此 timeout_ms: 5s 是有效配置;字段名带 _ms,业务仍可以用语言本身的时间单位。MySQL 原生连接与 socket 配置还有秒级粒度差异,不能把统一选项理解成完全相同的底层精度。时间与取消契约
云数据库与 CMS,把抽象拉回真实使用
远程数据源在 1.0 中采用结构化配置,并通过 allow 显式授权 host:port;旧 URL 入口仍用于 loopback。授权边界来自可信脚本的数据源配置,不是 OS 网络沙箱。HTTP 请求不能直接提供 credentials、SQL 或 allow 列表。数据源配置
TLS 配置区分 required、verify_ca、verify_identity 等模式,默认验证身份,错误验证不会回退明文。使用 MariaDB 客户端库构建时,支持身份验证,但拒绝仅 verify_ca 的模式;驱动差异明确返回,而不是装作支持。
RDS 可以按远程数据源配置,但真实 RDS 网络、认证、故障切换验收仍待专用实例。交付远程连接能力,不等于完成某个云部署环境的生产认证。 连接边界
CMS 的 install.hhy 则检验另一种边界:驱动负责执行和返回可靠结果,安装工具负责迁移顺序、状态记录、中断恢复和配置写入。
MySQL 部分 DDL 会隐式提交,因此“用事务包住全部建表语句”并不能保证安装整体回滚。安装应记录已完成步骤,重跑时先核验,再继续,而不是假设一切要么全有、要么全无。MySQL 隐式提交说明
这个首个应用也让我能克制范围:DB 1.0 优先把 MySQL/PostgreSQL 的常用访问能力做扎实。ORM、分库分表、分布式事务,都不应该仅因为别的生态有,就一起塞进驱动。
1.0 的验收里,我最看重哪组数字
验收记录包含 MySQL 8.4、PostgreSQL 17 的池、事务、游标、类型测试,以及 AST/Bytecode、双 prefork Worker、异常资源回收、TLS 负例与扩展崩溃恢复。CMS 测试是最小安装 fixture,覆盖中断恢复、重复安装、升级失败和逻辑备份恢复,不是一套随扩展发布的完整 CMS。验收记录
本地 macOS 的增量结果测试,每行使用 64 字节 payload 加一个 ID:
| 数据库 | 10 万行耗时 | 100 万行耗时 | 扩展峰值 RSS:10 万 / 100 万行 |
|---|---|---|---|
| MySQL | 0.210 s | 1.961 s | 14,024,704 / 14,254,080 字节 |
| PostgreSQL | 0.172 s | 1.585 s | 23,707,648 / 23,937,024 字节 |
两条路径的结果行数增加到十倍,扩展峰值 RSS 在这组负载下都只增加 229,376 字节,约 224 KiB。这比单独列出一个每秒行数,更能说明增量读取有没有避免随着总结果规模累积完整结果。它测的是扩展进程,不是整个 Web 服务,也不是任意行宽或查询计划的保证。原始数据与复现方式
另一个本地单客户端测试持续 300 秒:MySQL 完成 1,583,501 次 SELECT,PostgreSQL 完成 2,752,244 次;结束时各保留 1 条连接,pinned / in-use 都为 0。这提供了连接复用与资源归还的证据,但不能外推成多 Worker CMS 吞吐,也不是 Go、Python、PHP 的对照成绩。
24 小时长稳和真实 RDS 故障切换尚未完成。跨语言比较也仍需在同数据库、SQL、索引、TLS、网络和总连接预算下测量,并公开各自进程模型。验收范围
HHY Database 1.0 最值得做好的,是把 HHY 的 Flow、Stream、结构化错误与进程扩展机制,落到一套能够长期运行的数据访问语义上。 一次顺利的查询很容易展示;连接耗尽、导出中断、提交失联之后仍然知道发生了什么,才是我愿意把真实业务接进来的理由。
参考与阅读基线
DB 1.0.0 于 2026-09-07 随 HHY 1.5.0 发布。本文从早期设计文档出发,以提交 3af2f67f8a889afd65a0215dd5423430e7ea677e 的正式说明与验收记录核对实际交付范围。