分库路由 / 请求级 sticky / 脚手架增强(Phase 5)
27.1 分库(水平拆分)
框架在读写分离之外,进一步支持「分库(水平拆分)」:一张逻辑表按分片键分散到多个物理库,每个物理库在 config/database.php 的 connections 里是独立连接名。路由逻辑由 Mtphp\Database\ShardManager 承担(纯函数,仅算索引,不持有连接),上层通过 DB::shard() 取具体连接后继续链式查询。
配置(config/database.php 末尾 sharding):
'sharding' => [
// hash:数字键按值取模、字符串键 crc32 取模,适合均分
'orders' => [
'shards' => ['mysql_orders0', 'mysql_orders1', 'mysql_orders2'],
'algorithm' => 'hash',
],
// range:按单调递增边界分片(键 < 边界[i] 落分片 i,超出落最后一片)
'logs' => [
'shards' => ['mysql_logs0', 'mysql_logs1'],
'algorithm' => 'range',
'boundaries' => [1000000, 2000000],
],
],
// 同时需在 connections 中定义 mysql_orders0 / mysql_orders1 ... 等连接
使用:
// 按分片键取具体物理连接,之后与 DB::table() 完全一致
DB::shard('orders', $orderId)->table('orders')->where('id', $orderId)->get();
// 仅算连接名(便于调试/透传)
$connName = DB::shardName('orders', $orderId);
// 模型分库:声明 $shardGroup 后用 onShard()
class Order extends \Mtphp\Database\Model
{
protected static ?string $shardGroup = 'orders';
}
Order::onShard($orderId)->where('id', $orderId)->get(); // 返回 ModelBuilder,with/orderBy 等可用
要点:
- 分片键建议选「稳定且高频出现在查询条件里」的列(如 user_id / order_id),否则会跨片扫描;
- 每个分片是普通连接,分片自身也可配读写分离(在对应 connection 内加
read键),DB::shard()返回的连接会自动按连接级read包装; ShardManager路由是纯函数,配套单测覆盖 hash / range / 稳定性 / 分布均匀性。
27.2 请求级 sticky(写过即读主,免显式事务)
ReadWriteConnection 原本在「事务内」强制读主以保证读己写可见。新增请求级 sticky:一旦本请求发生过写(insert/update/delete/statement),后续读也强制走主库,直到请求结束复位——这样无需把读写包进事务也能看到自己刚写的数据。
- 复位边界:
App在每次引导(FPM 每请求)时调用DB::resetSticky(),避免 worker 复用把“写过即读主”泄漏到后续请求; - 手动控制:
$rw->setSticky(true|false)强制开关;$rw->resetSticky()复位;DB::resetSticky()复位全部读写分离连接; - 与事务共存:事务内读主(既有行为)优先级更高,二者不冲突。
// 典型诉求:写一条,紧接着读回刚写的(无需事务也能读己写)
DB::table('user')->where('id', 1)->update(['name' => 'new']);
$row = DB::table('user')->where('id', 1)->first(); // sticky 生效,读主库,看到 'new'
27.3 脚手架命令 make:migration / make:model / make:middleware
在既有 make:seeder / make:controller 基础上补齐三个高频脚手架(均已注册进 bin/mtphp):
# 迁移:文件名按 Ymd_His_名称 生成;名称含 create_..._table 时自动推断表名
php bin/mtphp make:migration CreatePostsTable
php bin/mtphp make:migration CreatePostsTable --create=posts
php bin/mtphp make:migration AddAgeToUsersTable --table=users
# 模型:生成 Mtphp\Database\Model 子类,app/Models;表名按类名 snake_case 推导
php bin/mtphp make:model Post
php bin/mtphp make:model Api/Order # 支持子目录
# 中间件:生成 handle(Request, callable): Response 骨架,app/Middleware
php bin/mtphp make:middleware CheckToken
php bin/mtphp make:middleware Api/AuthGate # 支持子目录
生成的迁移为「匿名类 + up()/down()」骨架(与既有迁移约定一致),可直接 php bin/mtphp migrate 执行;模型/中间件骨架含命名空间、必要 use 与示例方法,落地即可用。
27.4 跨分片聚合查询(分库下的“查全部 / 汇总”)
分库后单条查询走 DB::shard() 即可,但「统计总数、汇总金额、取前 N」这类需要扫多片的场景,框架提供一批聚合助手:
// 1) 逐分片执行:对组内每个物理连接各跑一次回调,返回 [shardName => 结果]
// 回调里是完整的 Query(where/orderBy/limit/count… 都能用),分片若配了 read 则自动读写分离
$per = DB::across('orders', fn ($q) => $q->table('orders')->where('status', 1)->get());
// 2) 合并多片结果:把 [shard => [rows]] 压平成统一行列表(用于跨片取数后聚合展示)
$rows = DB::mergeAcross($per);
// 3) 跨片按列求和:把 [shard => [rows]] 中每行的 amount 累加(订单金额汇总等)
$sum = DB::sumAcross($per, 'amount');
// 4) 仅要各片计数再汇总(count 返回标量,直接 array_sum)
$counts = DB::across('orders', fn ($q) => $q->table('orders')->count());
$total = array_sum($counts);
// 取出分片连接名列表(顺序即分片编号),便于自定义遍历
$names = DB::shardList('orders');
要点:
DB::across()内部为每个分片构造独立Query,分片自身若配了连接级read也会自动读写分离(写主读从 / sticky 同样生效);- 跨片查询无法利用单机索引做全局排序/分页/LIMIT——务必在每个分片内先用
orderBy/limit收敛,再mergeAcross后在应用层做二次归并(这也是主流分库方案的通行做法); - 纯函数
ShardManager::shardsFromConfig($cfg)负责从配置提取分片列表,便于无 App 环境单测。
27.5 Swoole 协程级 sticky(常驻模式)
请求级 sticky(27.2)已覆盖 FPM/CLI。框架进一步自动识别 Swoole 常驻模式:sticky 状态按「协程 ID」隔离,不同协程互不污染;非协程环境自动退化为请求级(键 _fpm)。
- 启用条件:检测到
Swoole\Coroutine且当前处于协程中(getCid() > 0)时才按协程隔离;未装 Swoole 时行为与 27.2 完全一致; - 复位时机:worker 常驻、不随请求销毁,因此应在
onRequest入口调用DB::resetSticky($cid)复位「当前协程」的 sticky(传协程 ID 仅清指定协程;不传则清空全部,适合 worker 启动/重启):
// Swoole Server 伪代码
$server->on('request', function ($req, $res) use ($server) {
$cid = \Swoole\Coroutine::getCid();
\Mtphp\Database\DB::resetSticky($cid); // 每个协程入口复位,避免“读过即读主”泄漏
// ... 分发到框架 handle() ...
});
27.6 跨分片全局归并(排序 / 分页 / 计数)
分库下无法用单库索引做全局排序与分页。正确姿势是:先在每个分片内 orderBy + limit 收敛(利用各分片自有索引),再把多分片结果“应用层二次归并”。框架提供三个助手:
// 1) 全局计数:逐分片 count 再求和(无需全局索引)
$total = DB::countAcross('orders', 'orders');
// 可带过滤:仅统计某时间窗
$today = DB::countAcross('orders', 'orders', fn ($q) => $q->whereDate('created_at','2026-10-11'));
// 2) 全局排序(稳定排序,同值保持合并先后)
$per = DB::across('orders', fn ($q) => $q->table('orders')->orderBy('amount','desc')->limit(100)->get());
$merged = DB::mergeAcross($per); // 合并多片行
$sorted = DB::sortAcross($merged, 'amount', 'DESC');
// 3) 全局分页:对排序后的跨片行切片,返回 items + 元信息
$page = DB::paginateAcross($sorted, $page = 1, $perPage = 20);
// $page = ['items'=>[...本页行...], 'total'=>总数, 'page'=>页码,
// 'perPage'=>每页条数, 'lastPage'=>末页, 'hasMore'=>是否还有下一页]
要点:
countAcross的$modify回调可对每个分片的Query追加where,实现“带条件的全局计数”;sortAcross是稳定排序(同值元素保留合并时的先后顺序),因此可安全做二次归并;paginateAcross入参非法(page<1回退 1、perPage<1回退 15)时自动规整,可直接交给视图 / API 响应。
27.7 脚手架 → 迁移 真实闭环(make → migrate)
make:migration 生成的迁移遵循「匿名类 + up/down」约定,可直接由 migrate 执行,形成完整闭环:
# 1) 生成迁移(自动推断表名;--create / --table 显式指定)
php bin/mtphp make:migration CreatePostsTable --create=posts
# 2) 执行未跑过的迁移(自动建表 + 记录到 migrations 仓储)
php bin/mtphp migrate
# 3) 重复执行幂等:已跑过的迁移不会重复执行
php bin/mtphp migrate
生成的迁移文件示例(database/migrations/<时间戳>_create_posts_table.php):
<?php
declare(strict_types=1);
use Mtphp\Database\Migration;
use Mtphp\Database\Schema\Blueprint;
use Mtphp\Database\Schema\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $t): void {
$t->id();
// $t->string('title');
$t->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
27.8 跨分片游标分页(keyset,避免深翻 OFFSET)
paginateAcross 用 offset 翻页,深翻时性能差且易漏读。分库下更推荐游标分页:每片各自按游标列取「> 上一页最大值」的前 N 条,合并后应用层二次归并取前 N,返回下一页游标。深翻 O(1)、天然适配分片。
// 首页(after=null,从最小 id 起);$column 建议用全局唯一且单调的列(如雪花 id),默认 id
$page = DB::cursorAcross('orders', 'orders', 'id', 20);
// $page = ['items'=>[...本页行...], 'nextCursor'=>下一页游标, 'perPage'=>20, 'count'=>本页条数]
// 翻下一页:把上一页的 nextCursor 传回即可
$next = DB::cursorAcross('orders', 'orders', 'id', 20, $page['nextCursor']);
// 末页 nextCursor 为 null,表示没有更多
$column取全局唯一且单调的列可避免边界同值漏读;若用会重复的列作为游标,请保证该列在分片内唯一或配合第二排序键;$modify回调可对每个分片追加where(如fn($q)=>$q->where('status',1)),过滤与游标叠加生效;- 实现要点:每个分片取「
$column > $after的前$perPage条(按$column升序)」即可保证全局 top-N 不漏(数学上,全局前 N 的任一行必居其所在分片的前 N)。
27.9 脚手架「生成即可用」闭环(model / controller / middleware 真实可运行)
四个 make:* 命令生成的不是“占位空壳”,而是落地即可运行的代码,并配有端到端演示测试:
make:migration→ 生成匿名类迁移(up/down),migrate直接跑通建表(见 27.7);make:model→ 生成继承Mtphp\Database\Model的模型,声明$table/$primaryKey,可直接ScaffoldDemo::create([...])/::all()增查;make:controller→ 生成index(Request $request)方法并response()->json([...])返回Response,可直接被路由分发;make:middleware→ 生成handle(Request $request, callable $next): Response中间件,套入管道即可前置/后置加工或短路。
tests/MakeScaffoldE2eTest.php 用临时工程根 + 内存 SQLite 解析器,经控制台依次生成四类脚手架 → migrate 建表 → 直接实例化生成的类并调用:模型能插入并查回数据、控制器方法能返回 Response、中间件能经管道返回内部响应,证明“生成代码真实可运行”且不污染仓库。
27.10 脚手架选项联动(--resource / --migration)
脚手架命令支持常用选项,进一步减少手动补齐:
make:controller <Name> --resource— 生成 7 方法 RESTful 资源控制器(index/create/store/show/edit/update/destroy),动作名与Router::resource()约定一一对应,可直接Route::resource('comments', CommentController::class)接管七条资源路由;各方法返回response()->json(...)并带正确状态码(store201 /destroy204)。不加--resource时仍生成单方法index骨架。make:controller <Name> --model=ModelName— 生成模型感知的 RESTful 资源控制器:方法与--resource一致,但index/show/store/update/destroy已直接调用指定模型(ModelName::all() / find() / create() / update() / delete()),落地即可接Route::resource使用,无需再手写查询。--model可传短名(Post→App\Models\Post,自动生成use语句)或完整类名(含反斜杠则原样引用)。make:model <Name> --migration(或-m) — 生成模型的同时联动生成建表迁移(Create{StudlyTable}Table --create=表名,表名沿用模型 snake_case 推导),随后php bin/mtphp migrate即可建表,省去手动建迁移的一步。
php bin/mtphp make:controller Comment --resource
php bin/mtphp make:controller Post --model=Post # 生成直接调用 Post 模型的资源控制器
php bin/mtphp make:model Post --migration # 等价于 -m:模型 + 建表迁移一起生成
tests/ScaffoldOptionsTest.php 验证:--resource 生成的控制器含全部 7 个动作且返回正确的 Response 与状态码;--model 生成的控制器正确 use 绑定模型、含 7 个资源动作,且经真实 migrate 建表后 index/store/show/update/destroy 全流程跑通(含删除后 404);--migration 生成的模型文件与建表迁移一一对应,跑 migrate 后表真实创建且模型可增查。
以上能力(分库路由、请求级 / 协程级 sticky、跨片聚合、跨片全局归并、跨片游标分页、四个脚手架命令、Model::onShard、make→migrate 闭环、脚手架生成即可用、脚手架选项联动)均配套零依赖单测(见
tests/ShardingTest.php、tests/StickyTest.php、tests/ShardAggTest.php、tests/ShardMergeTest.php、tests/MigrateRealTest.php、tests/MakeMigrateTest.php、tests/MakeScaffoldE2eTest.php、tests/ScaffoldOptionsTest.php),php tests/run.php一键跑全量,全绿。