Hyperf是基于Swoole的常驻内存协程框架,数据库操作全部经由连接池完成,这一点和传统FPM模式下的框架有明显区别。当项目规模扩大,用户、订单、日志等数据被拆分到不同的MySQL实例后,就需要在同一个服务里同时维护多条数据库连接。Hyperf的数据库组件对多连接的支持相当完整,配置思路也清晰,但跨库事务、连接池参数这些细节如果没有吃透,上线后很容易遇到数据不一致或者连接超时报错。本文从配置文件讲起,把多连接配置、模型绑定、运行时切换、事务处理和连接池调优的完整链路梳理一遍。

一、多连接配置结构:一个键名对应一条连接
Hyperf的数据库连接全部定义在config/autoload/databases.php文件中,最外层数组的键名就是连接名,框架约定其中必须存在一个名为default的默认连接,所有未显式指定连接的操作都会落到它上面。新增第二个、第三个库时,只需要在同一个文件里追加新的键名,每个键名下的结构和default完全一致,互相独立,并且各自拥有独立的连接池,互不干扰。
下面是一个典型的双库配置,default连接指向本机的商城主库,order连接指向另一台服务器上的订单中心库:
<?php
// config/autoload/databases.php
return [
'default' => [
'driver' => env('DB_DRIVER', 'mysql'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', 3306),
'database' => env('DB_DATABASE', 'shop'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'pool' => [
'min_connections' => 1,
'max_connections' => 10,
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
'max_idle_time' => 60,
],
],
// 新增的订单库连接,键名order就是连接名
'order' => [
'driver' => env('ORDER_DB_DRIVER', 'mysql'),
'host' => env('ORDER_DB_HOST', '192.168.1.100'),
'port' => env('ORDER_DB_PORT', 3307),
'database' => env('ORDER_DB_DATABASE', 'order_center'),
'username' => env('ORDER_DB_USERNAME', 'order_user'),
'password' => env('ORDER_DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'pool' => [
'min_connections' => 1,
'max_connections' => 10,
],
],
];
每个连接块里的pool部分是连接池配置,这是协程框架和传统框架差异最大的地方。FPM模式下每次请求新建连接、请求结束销毁,而Hyperf常驻内存,连接会被池化反复复用,所以池子的参数直接决定了高并发下的稳定性。几个关键参数的含义如下:
| 参数 | 作用说明 | 参考值 |
| min_connections | 池中常驻的最小连接数,服务启动后预热保持 | 1到5 |
| max_connections | 池中允许的最大连接数,超出后协程排队等待 | 10到50 |
| connect_timeout | 建立新连接的超时时间,单位秒 | 10.0 |
| wait_timeout | 从池里获取连接的等待超时,超时抛出异常 | 3.0 |
| heartbeat | 心跳检测间隔,负数表示不检测 | -1或60 |
| max_idle_time | 连接最大空闲时间,超过后被回收重建 | 60 |
除了连接池,其他参数和常见框架基本一致。driver目前主要支持mysql,charset建议统一用utf8mb4避免表情符号写入失败,prefix是表前缀,多库共用一套表结构时才需要设置。敏感信息建议全部放进.env文件,通过env函数读取,避免账号密码硬编码进仓库:
# .env 文件 DB_DRIVER=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=shop DB_USERNAME=root DB_PASSWORD=main_db_secret ORDER_DB_DRIVER=mysql ORDER_DB_HOST=192.168.1.100 ORDER_DB_PORT=3307 ORDER_DB_DATABASE=order_center ORDER_DB_USERNAME=order_user ORDER_DB_PASSWORD=order_db_secret
如果某个库还做了读写分离,可以在连接块里用read和write两个数组分别指定从库与主库地址,从库支持传数组实现简单的负载均衡,sticky设为true可以让一次请求中写入之后的读操作仍然走主库,避免主从延迟带来的脏读问题:
<?php
// 读写分离写法,read与write各自指定一组主机地址
'report' => [
'driver' => 'mysql',
'read' => [
'host' => ['192.168.1.101', '192.168.1.102'],
],
'write' => [
'host' => ['192.168.1.100'],
],
'sticky' => true,
'database' => 'report_center',
'username' => 'report_user',
'password' => 'report_secret',
'charset' => 'utf8mb4',
'prefix' => '',
],
二、切换连接的两种方式:Model静态绑定与Db动态指定
第一种方式是在模型类里通过$connection属性静态绑定。Hyperf的模型默认继承自Hyperf\DbConnection\Model\Model,不设置任何属性时走default连接,一旦给某个模型指定了$connection,它的所有查询、插入、更新、删除以及关联关系都会自动路由到对应的库,业务代码里完全无感知:
<?php
declare(strict_types=1);
namespace App\Model;
use Hyperf\DbConnection\Model\Model;
class Order extends Model
{
// 指定该模型使用order连接,而不是default
protected ?string $connection = 'order';
protected ?string $table = 'orders';
public bool $timestamps = true;
protected array $fillable = [
'order_no', 'user_id', 'amount', 'status',
];
}
使用官方脚手架生成模型时,可以直接通过--connection选项指定连接,生成的模型类会自动带上$connection属性,不需要手动修改,执行php bin/hyperf.php gen:model orders --connection order即可。这种方式适合领域边界清晰的场景,比如订单模型永远属于订单库,连接选择固化在模型层,代码语义明确,团队协作时不容易出错。
第二种方式是运行时用Db::connection动态切换。它适合临时性查询、聚合统计,或者一个方法里需要同时操作多个库的情况。connection方法返回指定连接上的查询构造器实例,后续的链式操作全部基于这条连接执行:
<?php
use Hyperf\DbConnection\Db;
// 查询订单库的表,返回该连接上的查询构造器
$orders = Db::connection('order')
->table('orders')
->where('status', 1)
->get()
->toArray();
// 统计订单库中金额大于等于100的订单数
$total = Db::connection('order')
->table('orders')
->where('amount', '>=', 100)
->count();
// 在order连接上开启事务,闭包内的操作都受它保护
Db::connection('order')->transaction(function () {
Db::connection('order')->table('orders')->insert([
'order_no' => 'SN1001',
'amount' => 199.00,
'status' => 0,
]);
Db::connection('order')->table('order_items')->insert([
'order_no' => 'SN1001',
'sku' => 'A100',
'num' => 1,
]);
});
两种方式怎么选可以参考一个原则:长期稳定、有明确归属的表用模型绑定,让连接选择固化在模型层;临时性、一次性的跨库操作用Db::connection,灵活且不侵入模型定义。另外要注意,Db::connection('order')每次调用拿到的都是order连接池中的可用连接,用完自动归还,不需要也不应该手动管理连接的生命周期,这是协程连接池和手写PDO代码最大的思维差异。
三、跨库事务为什么不生效,以及补救方案
这是多库项目里最容易踩的坑。事务的本质是数据库连接上的一个状态,MySQL的事务边界无法跨越实例。Hyperf的transaction方法只在调用它的那条连接上执行begin、commit或rollback,闭包里如果切到了别的连接,那部分操作完全不受事务保护。先看一段错误示范:
<?php
use Hyperf\DbConnection\Db;
// 错误示范:外层事务只覆盖default连接
Db::connection('default')->transaction(function () {
// 这一步在default连接的事务保护范围内
Db::table('users')->where('id', 1)->decrement('balance', 100);
// 这一步走的是order连接,完全不受上面事务控制
Db::connection('order')->table('orders')->insert([
'order_no' => 'SN1002',
'amount' => 100.00,
'status' => 0,
]);
// 异常抛出后,扣款回滚了,订单却已经真实落库
throw new \RuntimeException('模拟业务异常');
});
这段代码执行后会得到一个很尴尬的结果:异常抛出时,default连接上的余额扣减被回滚了,但order连接上的订单已经真实落库。原因在于Db::connection('order')拿到的是另一条物理连接,它根本不知道default连接上开着事务。数据不一致就是这么产生的,而且这类问题在测试环境往往发现不了,只有并发上来或者异常频发时才暴露,排查起来非常痛苦。
正确的思路是承认跨库事务不存在,转而用业务手段保证最终一致。最直接的是补偿模式:分库分别开启事务,失败时执行反向操作,把已成功的那一步撤销或者标记为异常状态:
<?php
use Hyperf\DbConnection\Db;
$orderNo = 'SN1003';
try {
// 第一步:在订单库写入订单,独立事务
Db::connection('order')->transaction(function () use ($orderNo) {
Db::connection('order')->table('orders')->insert([
'order_no' => $orderNo,
'amount' => 100.00,
'status' => 0,
]);
});
// 第二步:在默认库扣减余额,独立事务
Db::transaction(function () {
Db::table('users')->where('id', 1)->decrement('balance', 100);
});
} catch (\Throwable $e) {
// 补偿动作:把已写入的订单标记为失败,等待对账或重试
Db::connection('order')->table('orders')
->where('order_no', $orderNo)
->update(['status' => 2, 'remark' => '扣款失败,等待补偿']);
throw $e;
}
补偿模式的优点是实现简单、不引入额外组件,缺点是补偿逻辑要覆盖所有失败分支,代码量随业务复杂度上升。如果一致性要求更高,可以引入消息队列做异步对账,或者使用Seata这类分布式事务组件,Hyperf社区也有对应的集成方案。实际项目中,大部分场景用补偿加定时对账就足够了,不必为了少数强一致场景把整条链路复杂化。
四、连接池参数怎么调,常见报错怎么排查
配置了多连接之后,连接池的总量控制变得重要。假设一台MySQL实例的max_connections是500,服务部署了4个节点,每个节点配置了两个连接池、每个池的max_connections是20,那么最坏情况下这台MySQL要承受4乘2再乘20共160条连接,还在安全范围内。计算公式就是节点数乘以每节点连接池数量乘以每池上限,再留出百分之三十左右的余量给运维工具和其他业务。min_connections不建议设太大,闲置连接既占MySQL资源也占服务内存,低峰期纯属浪费。
几个高频报错值得提前记住,出现时按症状对号入座:
- 报
Connection timeout或者Wait timeout,通常是连接池被占满,协程排队拿连接的时间超过了wait_timeout。要么适当调大max_connections,要么排查慢SQL,让连接尽快归还池子。 - 报
MySQL server has gone away,多半是连接闲置太久被服务端踢掉了。把连接池的max_idle_time调到小于MySQL端的wait_timeout,或者把heartbeat设为正数开启心跳保活。 - 报
Too many connections,说明所有客户端加起来超过了MySQL的max_connections上限,需要按上面的公式重新核算每个池的上限,必要时拆分实例部署。
最后提一个排查技巧:连接问题优先看慢日志和连接持有时间。Hyperf的连接池在借出和归还时都有事件钩子,可以配合中间件记录每次请求持有的连接数和时长,定位到长期不归还连接的代码段,往往就是一段没有遍历完的游标、一个忘记提交的事务,或者一次耗时过长的外部调用夹在事务中间。多库架构本身并不复杂,把每条连接的来龙去脉想清楚,配置和调优就都有了依据。

