webman 数据库实战:连接池、ORM 选型、事务边界与大数据处理

webman 数据库实战:连接池、ORM 选型、事务边界与大数据处理

admin
2026-08-15 / 0 评论 / 7 阅读

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,   // ② 字段信息缓存(常驻内存的红利)
        ],
    ],
];

三层兜底缺一不可:

  1. break_reconnect => true:ORM 层检测断连自动重连
  2. 心跳保活:定时进程每 5 分钟发一条 SELECT 1,让连接永不超时(webman 的 config/process.php 里加自定义进程实现)
  3. 重试幂等:断线重连只对查询安全,写操作重试必须保证幂等,否则可能双写
// 心跳进程示例
return [
    'db-keepalive' => [
        'handler' => function () {
            while (true) {
                Db::query('SELECT 1');
                sleep(300);
            }
        },
    ],
];

三、ORM 选型:think-orm 还是 Laravel Eloquent?

webman 官方两者都支持,选型看团队基因:

维度think-ormEloquent(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

评论 (0)

取消
0:00