webman 数据库实战:连接池、ORM 选型、事务边界与大数据处理
数据库是 webman 与传统 FPM 框架差异最大的环节。FPM 下每个请求开新连接、请求结束关闭,天然隔离;webman 的每个 worker 进程持有自己的连接并跨请求复用——连接管理从"框架的事"变成了"你的事"。这篇讲透连接池配置、think-orm 与 Eloquent 的选型、事务的正确边界,以及千万级数据的处理姿势。
一、常驻内存下的连接模型
先看清楚 webman 的数据库连接结构:
worker-1 ─── 连接A(MySQL)──┐
worker-2 ─── 连接B ├── MySQL max_connections 上限(如 1000)
worker-3 ─── 连接C │
...(16 个 worker) │
└── Redis 连接同理推论一:最大连接数 = worker 进程数 × 每进程连接数。 16 个 worker、每个用了 2 个连接配置(读写分离),就是 32 个常驻连接。上线前先算这笔账,别和 MySQL 的 max_connections 打架。
推论二:连接是懒建立的。 进程启动时不连库,第一次查询才建立。所以刚 reload 完的进程有冷启动延迟,压测时记得先预热。
推论三:连接会"死"。 MySQL wait_timeout(默认 8 小时)内没有活动,服务端单方面断开,webman 侧还握着死连接,下一个请求直接 MySQL has gone away。
二、断线重连:三层配置兜底
以 think-orm(webman 默认集成)为例:
// config/thinkorm.php
return [
'default' => 'mysql',
'connections' => [
'mysql' => [
'type' => 'mysql',
'hostname' => '127.0.0.1',
'database' => 'app',
'username' => 'root',
'password' => 'root',
'charset' => 'utf8mb4',
'break_reconnect' => true, // ① 断线自动重连
'fields_cache' => true, // ② 字段信息缓存(常驻内存的红利)
],
],
];三层兜底缺一不可:
break_reconnect => true:ORM 层检测断连自动重连- 心跳保活:定时进程每 5 分钟发一条
SELECT 1,让连接永不超时(webman 的config/process.php里加自定义进程实现) - 重试幂等:断线重连只对查询安全,写操作重试必须保证幂等,否则可能双写
// 心跳进程示例
return [
'db-keepalive' => [
'handler' => function () {
while (true) {
Db::query('SELECT 1');
sleep(300);
}
},
],
];三、ORM 选型:think-orm 还是 Laravel Eloquent?
webman 官方两者都支持,选型看团队基因:
| 维度 | think-orm | Eloquent(illuminate/database) |
|---|---|---|
| 上手成本 | ThinkPHP 系团队零成本 | Laravel 系团队零成本 |
| 模型功能 | 基础够用 | 关联预加载、访问器、软删更丰富 |
| 常驻内存适配 | fields_cache 等针对性优化 | 需要自己注意静态缓存 |
| 生态迁移 | TP 项目平移 | Laravel 项目平移 |
中大型项目的建议:ORM 只做"查询构造 + 结果映射",复杂查询直接裸 SQL 或查询构造器。模型层越薄,常驻内存下的心智负担越小。
一个 Eloquent 用户必须知道的区别:FPM 下 DB::connection() 每请求新建,webman 下连接跨请求驻留——事务没有提交/回滚,下一个请求会接着在这个事务里跑,数据不一致的幽灵bug就是这么来的。think-orm 的 Db::startTrans() 同理。
// ❌ 事故现场:异常没回滚,连接带着事务回到池里
try {
Db::startTrans();
// ... 写操作
throw new \RuntimeException('boom'); // 没有 catch 回滚!
} finally {
Db::commit(); // finally 里 commit,异常时也 commit 了半截数据?
}四、事务的正确姿势
铁律一:事务边界必须完整闭合
try {
Db::startTrans();
$this->deductStock($skuId, $num); // 扣库存
$this->createOrder($uid, $skuId); // 建订单
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e; // 异常继续抛,别吞
}铁律二:事务里禁止外部 IO
事务持锁期间调外部 HTTP / 发 MQ / 写 Redis,一旦外部服务慢,数据库连接和行锁被拖住,并发一上来就是雪崩。
// ❌ 事务里调第三方风控接口(300ms),行锁拖 300ms
Db::startTrans();
$stock = Stock::lock(true)->find($skuId); // SELECT ... FOR UPDATE
RiskApi::check($uid); // 外部调用!
$stock->num -= 1; $stock->save();
Db::commit();
// ✅ 外部检查前置,事务只做纯 DB 操作
RiskApi::check($uid); // 先调外部
Db::startTrans();
$stock = Stock::lock(true)->find($skuId);
if ($stock->num < 1) throw new OutOfStock();
$stock->num -= 1; $stock->save();
Db::commit();
PublishOrderEvent::dispatch($order); // 后置异步动作铁律三:事务尽量短,锁尽量准
- 悲观锁(
lock(true))只锁必要的行,锁定前先校验数据存在的条件 - 能用唯一索引防重(幂等)就别用"先查再插 + 锁"
补充:嵌套事务与 savepoint
think-orm 支持 startTrans 嵌套(底层转 savepoint),但事务边界只写在 Service 层一处,不要 Controller 包一层、Service 再包一层——嵌套事务的回滚语义很容易把人绕晕。
五、大数据量:分批处理的三个工具
1. chunk 分批读
User::where('status', 1)->chunkById(1000, function ($users) {
foreach ($users as $user) {
// 逐批处理
}
});用 chunkById 而不是 chunk:处理过程中如果更新了 offset 相关字段,chunk(基于 offset/limit)会漏数据或死循环,chunkById(基于主键游标)不会。
2. 慎用 ORM 批量,必要时裸 SQL
ORM 逐条 save 在万级数据下慢得无法接受:
// ❌ 10 万条逐条 update:几十分钟
foreach ($rows as $row) { Model::where('id', $row['id'])->update($row); }
// ✅ 事务 + 批量拼接(注意分批,每批 ≤ 1000)
Db::startTrans();
foreach (array_chunk($rows, 500) as $batch) {
Db::table('users')->upsert($batch, ['id'], ['name', 'status']); // 批量 upsert
}
Db::commit();3. 超大导出:游标 + 流式输出
百万行导出别 ORM ->get() 全量载入(内存直接爆),用游标逐行读、边读边写 CSV 流:
$fp = fopen('php://output', 'w');
User::where('created', '>', $start)->select()->cursor()->each(function ($u) use ($fp) {
fputcsv($fp, $u->toArray());
});cursor() 用 PDO 的 unbuffered 模式,内存占用恒定。
六、读写分离与多库配置
'connections' => [
'mysql' => [ /* 主库写 */ ],
'mysql_read' => [ /* 从库读 */ ],
],think-orm/Eloquent 都支持配置读写分离自动路由,但强制主读(写后立读的场景:下单后立即查详情)必须显式指定主库连接,否则撞上主从延迟会查到旧数据:
// 写后立刻要读的,强制走主库
$inDb = Order::on('mysql')->find($orderId);七、上线前检查清单
- [ ] 最大连接数核算:worker 数 × 每进程连接数 < MySQL max_connections × 0.8
- [ ]
break_reconnect开启 + 心跳进程就位 - [ ] 事务全部 try/catch/rollback 完整闭合(全局搜
startTrans逐个人工审计) - [ ] 长事务里无外部 IO(APM 看事务平均持锁时长)
- [ ] 大数据任务用 chunkById / cursor,无全量 ->get()
- [ ] ORM 字段缓存开启,生产关掉 SQL 日志
写在最后
数据库层的所有坑都指向同一个根因:连接的生命周期从"请求级"变成了"进程级"。把连接当进程级资源来管理(池化、心跳、重连),把事务当危险品来对待(短小、闭合、无 IO),webman 的数据库层就稳了。下一篇进入性能深水区:协程、连接池调优与压测。
评论 (0)