Skip to content

数据库连接懒加载、租约管理与作用域归还 - #7819

Open
tw2066 wants to merge 10 commits into
hyperf:masterfrom
tw2066:newBaseQuery
Open

tw2066 wants to merge 10 commits into
hyperf:masterfrom
tw2066:newBaseQuery

Conversation

@tw2066

@tw2066 tw2066 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

数据库连接懒加载、租约管理与作用域归还

概要

当前 Model::query() 等仅构建查询的操作就会借用连接池槽位,并持有到协程结束。在消费者、常驻 worker 或包含其他耗时操作的请求中,连接的占用时间可能远长于 SQL 的执行时间。

本 PR 为 hyperf/db-connection 增加可选的延迟借用和使用后归还:构建查询不占池,执行时才借用连接;开启 release_after_use 后,在完整数据库操作结束、且没有事务或其他持有条件时归还。默认仍使用原有 eager 行为。

配置与使用

两个选项位于 databases.{name} 下,与 pool 同级,默认均为 false:

'default' => [
    // 原有 driver、host、database 等配置……
    'lazy' => true,
    'release_after_use' => true,
    'pool' => [
        'min_connections' => 1,
        'max_connections' => 10,
    ],
],
  • 只开启 lazy:延迟到首次执行时借用,随后持有到协程结束。
  • 开启 release_after_use:隐含开启 lazy,在满足生命周期条件时短借短还。
$query = User::query()->where('active', 1); // 构建查询不占池
$sql = $query->toSql();                   // 内置驱动编译 SQL 不建立 PDO
$users = $query->get();                   // 执行时借用,操作结束后判断是否归还

多条命令依赖同一个租约时,可以使用 Db::withConnection(),无需额外开启事务。下面是 MySQL 会话变量的示例;读写分离时显式从写 PDO 读取该变量:

Db::withConnection(function ($db) {
    $db->statement('SET @trace_id = ?', ['task-1']);

    try {
        return $db->selectFromWriteConnection('SELECT @trace_id AS trace_id');
    } finally {
        $db->statement('SET @trace_id = NULL');
    }
});

请求内临时调整策略,可以使用支持嵌套及异常恢复的协程级作用域:

Db::withReleaseAfterUse(false, function ($db) {
    // 此范围内保留借用的连接,退出时恢复此前的策略。
    $db->select('SELECT 1');
    $db->select('SELECT 2');
});

实现原理

实现将编译设置、逻辑连接、借用所有权和 SQL 执行分开。上层 Builder 始终绑定协程内的逻辑连接,物理连接则可以在多次完整操作之间归还、重新借用。

flowchart TD
    A[Db / Model / Query Builder] --> R[ConnectionResolver:协程 Context]
    R --> S[LazyConnection:逻辑 Session]
    S --> M[ConnectionMetadata:无 IO 编译设置]
    S --> O[操作计数、显式 pin 与归还策略]
    O --> L[ConnectionLease:所有者与借用代次]
    L --> W[DbPool 中的 Connection 包装]
    W --> D[真实 driver Connection]
    D --> P[写 PDO / 可选读 PDO]
    D -. 查询观察器 .-> S
Loading

图中的操作计数、pin 和策略由 LazyConnection 内部字段与方法管理。

1. 构建查询与借用连接分开

ConnectionResolver::connection() 先查找当前协程的 Context。需要 lazy 时,创建并缓存 LazyConnection;此时没有调用 pool->get()。普通自动归还只清掉代理内部的租约,Context 中的逻辑连接继续存在,因此之前创建的 Builder 下次执行时仍能通过同一个入口借用连接。

query()、table()、toSql() 依赖 ConnectionFactory::makeMetadata() 提供的 ConnectionMetadata,其中只有配置、grammar、processor、数据库名、表前缀及 Builder 类型信息。内置驱动直接创建这些编译对象,不构造可执行 Connection,因此 SQLite 构造时的 PRAGMA 也不会被触发。SQL Server 的元数据包含专用 Builder 类型,保留其方言行为。

执行查询时,borrow() 才通过 resolver 获取租约,并把逻辑连接的编译设置应用到真实 driver。第三方驱动没有无 IO 元数据契约时,明确回退到借用并固定真实连接,使用该驱动自身的编译设置。

2. 用租约表达一次借用的所有权

DbPool::get() 调用池包装的 markBorrowed(),设置借用状态并递增 leaseGeneration。ConnectionLease 记录当前协程 ID 和这次借用的代次;每次访问都检查协程归属、租约是否已关闭、代次是否仍然匹配。

例如 C1 的第一份租约归还后,C1 被另一个使用者借出,代次会从 G1 变为 G2。旧租约无法再操作 C1,即使池包装对象本身仍是同一个 PHP 对象。正常归还会消费租约并清空其引用,重复归还不再入池;池包装也通过借用标记阻止重复入池。

acquireLease() 始终从池获取新的借用,不接管 Context 中已有、已由其他对象负责清理的连接,从入口上避免多个清理所有者共享同一次借用。

3. 归还发生在完整操作结束时

普通执行方法统一进入 runOperation()。它先校验逻辑连接的状态和协程归属,再增加活动操作计数;执行和回调全部结束后,在 finally 中减少计数并尝试归还:

public function runOperation(Closure $callback): mixed
{
    $this->assertUsable();
    ++$this->operations;

    try {
        return $callback($this->borrow()->getDatabaseConnection());
    } finally {
        --$this->operations;
        $this->releaseIfIdle();
    }
}

执行钩子或事件中再次查询时,内层操作只会把计数从 2 减到 1,外层仍在使用租约,因此无法提前归还。异常路径也经过相同的计数恢复过程。

不同操作的完整边界如下:

操作 租约需要保留到
普通物化查询 执行、取结果、钩子及事件处理结束
insertGetId() 插入及 ID 获取全部结束
cursor() 生成器耗尽或销毁
transaction() 整个事务回调及重试处理结束
显式 beginTransaction() 事务层级回到 0
pretend() 整个回调结束,预演及临时日志状态恢复
withConnection() 显式固定范围退出,再按策略判断是否归还

insertGetId() 通过可选的 ConnectionOperationInterface 包裹 Processor 调用,并在操作内用克隆的 Builder 绑定真实 driver。这样 MySQL/SQLite 的 insert + lastInsertId、PostgreSQL RETURNING、SQL Server 的 ID 获取都使用同一个租约。Processor 内部不会因某条语句返回而触发代理归还,普通 insert() 则不必为了 ID 获取持续占池。

事务和 pretend() 的用户回调收到的是逻辑连接,所以通过回调参数、Db 或 Model 再次执行的操作仍进入同一入口。pretend() 使用 finally 恢复之前的预演及日志状态,嵌套预演结束也不会关闭外层预演。

4. 游标迭代覆盖整个生成器生命周期

cursor() 是生成器。仅创建生成器不会执行函数体;第一次迭代才增加 operations 并借用连接,计数一直保留到生成器的 finally 执行。

以游标内嵌套 update() 为例,假定进入循环前没有活动操作或租约:

时刻 operations 是否允许普通自动归还
创建 cursor 生成器 0 尚未借用
开始迭代、获取第一行 1 否
执行内部 update 2 否,复用当前租约
update 返回、继续迭代 1 否,外层游标仍在使用
游标耗尽或销毁 0 继续检查事务、pin、sticky 和策略

如果 break 后仍保存生成器,它尚未销毁,租约也继续保留。协程清理时若生成器仍处于活动状态,则使底层 driver 失效,避免未读完的结果流进入下一次借用。

这里保证的是租约生命周期。MySQL 非缓冲结果未读完时,协议仍禁止在同一个 PDO 上执行另一条 SQL;操作计数不会改变这个数据库限制。

5. 统一判断是否可以自动归还

releaseIfIdle() 只在下列条件全部满足时归还:

  • 逻辑连接未关闭,且当前存在有效租约。
  • operations == 0、pins == 0,没有作用域外暴露的原始句柄。
  • 当前有效的 release_after_use 策略为 true。
  • 真实连接事务层级为 0,并具有框架可观测状态契约。
  • 真实连接不处于预演或物理查询日志范围。
  • 没有“sticky 开启且真实连接已执行写入”的持有条件。

withConnection() 增加显式 pin,并把整个回调放在操作范围内,退出时在 finally 中解除 pin。归还策略只决定空闲时是否归还,不能突破活动操作、事务或 pin 的保护。

暴露 PDO、Schema Builder、真实连接或调用未知 API 时,代理保守地记录原始句柄和不透明状态。作用域外访问会持续固定租约,直到显式释放或协程清理;显式作用域内访问则在范围结束后按当前策略判断归还。真正归还时,使底层 driver 失效,避免无法可靠重置的状态被后续使用者继承。

6. 状态分别归逻辑连接和真实 driver 管理

查询日志存放在逻辑连接中。每次借用时给真实 driver 安装查询观察器,由 logQuery() 将查询信息送到逻辑连接;归还后再次借用其他物理连接,日志仍然连续。池归还前移除观察器并清理物理日志、预演及修改标记,避免请求状态跨租约残留。

编译设置也归逻辑连接所有。setter 更新逻辑元数据并同步当前租约,fluent 调用返回逻辑入口。租约记录借用前的设置,归还前恢复 grammar、processor、表前缀、数据库名及需要恢复的 schema grammar;恢复失败时记录错误并使 driver 失效,再归还池包装,保留原始查询或回调异常。

事务层级和实际写标记继续由真实 driver 管理。sticky 判断读取 hasModifiedRecords(),因此 PostgreSQL Processor 直接修改真实连接的路径也能被识别,无需在代理中额外维护写状态镜像。只读事务不会合成写标记;满足 sticky 写后条件时,继续持有同一个物理租约。

7. 协程清理与临时策略

逻辑连接构造时就记录所属协程并注册清理 defer,无需等首次查询才注册。清理先把逻辑连接标记为关闭,再按对象身份移除 Context 中对应的入口并归还租约。之后使用捕获的旧代理会抛出明确异常;晚期 defer 通过 resolver 获取连接时,可以建立新的逻辑入口及其清理责任。

策略默认值保存在 worker 的 resolver 中。withReleaseAfterUse() 在当前逻辑连接上临时保存覆盖值,并用 try/finally 恢复;嵌套调用恢复父范围的值,当前连接覆盖优先于 worker 默认值和配置。lazy 未开启时,resolver 在创建入口期间使用临时 Context 标记选择逻辑连接,随后恢复该标记,再进入回调范围,避免范围退出时错误读取临时策略。

兼容性与边界

  • 默认 eager 配置和数据库 ConnectionInterface 的必需方法保持兼容。ConnectionOperationInterface 是可选扩展能力。
  • enableReleaseAfterUse()、disableReleaseAfterUse()、resetReleaseAfterUse() 设置当前 worker 的默认策略,不广播到其他 worker。已有逻辑连接在下一次归还判断时读取策略;已有 eager 句柄保持原生命周期。协程级临时策略须在获取 eager 句柄之前设置,否则抛出明确异常。
  • 自定义驱动可通过 Connection::resolverFor() 的第三个可选参数注册无 IO 元数据工厂,也可覆盖 ConnectionFactory::makeMetadata()。缺少元数据契约时回退到固定真实连接,可能在构建期间发生 IO;缺少可观测状态契约的连接采用保守的作用域生命周期。
  • PDO、Schema Builder、真实连接及未知 driver API 会固定租约。这类 API 可以改变框架无法可靠重置的状态,因此归还时失效底层 driver,保留池包装并在下次借用时重建。物理句柄不得跨作用域或协程保留。
  • withConnection() 固定的是池连接租约;读写分离仍可能包含两个 PDO。普通 SQL 创建的会话变量、临时表或锁,需要显式选择正确 PDO 并在归还前清理。
  • 游标内嵌套 update() 不会导致租约提前归还。MySQL 默认缓冲查询支持该写法;非缓冲查询在结果未读完时不能在同一个 PDO 上执行其他 SQL。大表处理可使用独立命名连接分别读写,或使用 chunkById() 分批处理。
  • 开启 sticky 后,写入仍会持续占用同一个租约;本 PR 没有将其改为“归还物理连接、仅保留后续写节点路由”。
  • 协程外没有自动清理,需要在使用结束时显式关闭逻辑连接。

验证

本地 PHP 8.4.24、Swoole 环境下,以下多次运行累计 288 个测试、927 个断言通过:

验证范围 测试 断言
数据库核心、Query Builder、Processor、SQLite、PostgreSQL 编译/处理链及 lazy 生命周期 271 859
无需真实 MySQL 服务的既有 eager 连接测试 7 22
真实 MySQL 生命周期集成测试 10 46

新增 26 个生命周期回归用例,覆盖真实 SQLite 两连接交替、ID 获取、预演不写入、重入、句柄固定、日志、编译设置恢复、策略隔离、defer 顺序、废弃游标、失效租约及清理异常。

真实 MySQL 的 10 个用例在本机服务上执行,验证两连接 ID 获取、保存点、事务异常、协程退出回滚、预演、sticky 路由、作用域会话变量、临时表隔离及非缓冲游标销毁。读写 PDO 使用同一服务器,通过 CONNECTION_ID() 验证路由选择;未验证独立主从节点的复制延迟。PostgreSQL / SQL Server 使用真实框架处理链及 PDO stub,尚未进行真实服务集成验证。

MySqlConnectionLifecycleTest 通过 HYPERF_MYSQL_DATABASE 显式启用,连接参数来自 HYPERF_MYSQL_HOST、HYPERF_MYSQL_PORT、HYPERF_MYSQL_USER 和 HYPERF_MYSQL_PASSWORD。测试使用随机命名的表并在结束后清理;未配置环境时跳过,凭据不进入仓库。

其他检查:

  • 仓库配置下的 PHPStan 通过;该配置排除了 database 和 database-pgsql 源码。
  • 改动 PHP 文件通过语法及代码格式检查,git diff --check 通过。
  • 有 5 个既有 PHPUnit deprecation,无测试失败。
  • 当前本地 vendor 缺少项目已声明的 PHP 8.5 数组 polyfill,测试使用临时脚本补齐 array_first() / array_last();临时脚本不属于补丁,依赖版本及锁文件未修改。

- 在 ConnectionResolver 中实现懒加载连接逻辑,通过 databases.{name}.lazy 配置控制
- 新增 LazyConnection 类作为连接池的延迟代理,只在首次实际使用时才获取真实连接
- 懒加载连接支持元数据方法调用,无需占用连接池资源
- 查询构建等操作延迟到执行时才解析真实连接
- 添加相关单元测试验证懒加载行为和连接释放机制
- 新增 databases.{name}.lazy 配置选项支持延迟连接
- 新增 databases.{name}.release_after_use 配置选项支持使用后立即释放连接
- 实现 LazyConnection 代理类处理延迟连接逻辑
- 在 release-after-use 模式下查询完成后立即释放连接回池
- 事务期间保持连接持有确保数据一致性
- 粘性读写连接在写入后保持连接防止读取不一致
- 查询日志启用时阻止连接释放保证日志完整性
- 协程环境下正确管理连接生命周期和清理机制
- 添加了 Db::enableReleaseAfterUse()、Db::disableReleaseAfterUse() 和 Db::resetReleaseAfterUse() 方法用于运行时切换 release_after_use 选项
- 在 ConnectionResolver 中添加了运行时重写机制,支持全局和协程级别的连接释放策略控制
- 更新了 LazyConnection 类以支持运行时动态调整连接释放行为
- 修复了上下文管理逻辑,确保运行时切换能够立即生效
- 添加了协程本地的连接释放标志覆盖功能
- 增强了测试用例以验证运行时控制功能的正确性
- 添加了 databases.{name}.lazy 配置选项说明
- 添加了 databases.{name}.release_after_use 配置选项说明
- 添加了 Db::enableReleaseAfterUse 等运行时控制方法说明
- 补充了懒加载连接代理的工作机制描述
- 更新了相关 Pull Request 链接为实际编号 hyperf#7819
- 在游标流式传输期间添加 streamingCursors 计数器防止连接被意外释放
- 修改 releaseAfterUse 方法增加对 streamingCursors 的检查
- 更新文档中关于连接释放条件的描述,补充游标流式传输场景
- 添加嵌套语句在游标迭代期间的测试用例
- 实现 PDOStatementStubPHP8 中的行数据返回功能用于测试
- 修复了在启用 release_after_use 选项时,粘性读写连接在写操作后的连接持有逻辑
- 更新了文档中关于连接在查询日志启用时仍被持有的说明
- 添加了测试用例验证粘性写入连接在协程间的重置行为
- 确保读取-你的-写入(read-your-write)行为在粘性模式下正常工作
- 修复了游标迭代完成后连接的释放逻辑
- 补充了 release_after_use 选项的详细行为描述
- 添加了 insert 操作不会立即释放连接的说明
- 更新了运行时切换 release_after_use 配置的方法文档
@tw2066
tw2066 marked this pull request as draft October 5, 2026 09:19
- 添加了 ConnectionOperationInterface 接口支持连接操作处理
- 实现了 LazyConnection 类用于延迟连接池连接获取
- 新增 release_after_use 配置选项实现连接使用后立即归还
- 添加 databases.{name}.lazy 和 release_after_use 配置选项
- 实现 ConnectionLease 管理连接租约生命周期
- 支持查询日志、事务和写入记录的状态管理
- 添加了连接元数据解析器支持轻量级查询构建
- 实现了连接借用和归还的安全机制
- 支持协程本地连接策略配置和临时覆盖功能
@tw2066 tw2066 changed the title 数据库连接懒加载、用后归还与运行时开关 数据库连接懒加载、租约管理与作用域归还 Oct 5, 2026
tw2066 added 2 commits October 5, 2026 21:46
- 详细说明了懒加载和使用后释放连接的配置选项
- 解释了粘性连接的行为和查询日志存储机制
- 描述了需要相同连接的命令作用域使用方法
- 介绍了临时和工作进程策略的配置方式
- 说明了协程清理和自定义驱动的实现要求
- 提供了 MySQL 集成测试的运行指南
- 添加了连接租赁激活状态检查方法 isActive()
- 实现了 Schema 语法的正确恢复机制,包括克隆和重置逻辑
- 修复了外部释放连接时的状态恢复和异常处理
- 改进了租赁回调注册的安全性检查
- 解决了懒加载连接的延迟清理问题
- 添加了对无 Schema 支持驱动的元数据回退处理
- 优化了查询日志在逻辑会话中的管理
- 修复了事务和连接复用时的状态隔离问题
@tw2066
tw2066 marked this pull request as ready for review October 6, 2026 06:47

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant