上个月我做了一个数据看板项目,需求不复杂:有一个主数据库保存了所有门店的基础信息,但是每个门店的订单数据存在着各自的物理数据库里。也就是说,门店A的数据在库A,门店B的数据在库B。前端通过 /api/shop/orders?shopId=12 来查订单,后端得根据shopId去路由到正确的数据库。
一开始我照着网上的想法,硬编码了十个连接配置,然后在控制器里写了一个巨大的switch。差不多这样:
switch ($shopId) {
case 1:
$orderModel = new Shop1Order();
break;
case 2:
$orderModel = new Shop2Order();
break;
// 以后有三十个店,我就得复制三十个case
}
每来一个店,就要新建一个模型,再加一个 case。代码越来越长,最后我自己看到都想吐。后来发现 ThinkPHP 的模型基类里其实有一个 `on()` 方法,可以直接指定连接名;再配合自定义一个“连接映射处理器”,就能把这种又臭又长的判断收敛到干干净净的几行里。
先说目标:不准备每个门店写一个模型
假设订单表结构都一样,我们只需要一个 `Order` 模型类就够了。要切换数据库时,不用新建模型,换成不同连接名再查询就行。TP8 中可以这样写:
$result = Order::on('shop_a_db')->where('user_id', 100)->select();
这里的 `shop_a_db` 是在 `config/database.php` 的 `connections` 里配置好的一个连接标识。关键点来了:我们不该在控制器里把连接名写死,而是要通过一份映射表根据门店id查出来。
第一步:在 database.php 里定义多个连接
在 ThinkPHP 8 里,默认配置文件内容大致如下:
return [
'default' => 'mysql',
'connections' => [
'mysql' => [
'type' => 'mysql',
'hostname' => '127.0.0.1',
'database' => 'main_db',
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4',
],
// 下面是我新加的
'shop_a_db' => [
'type' => 'mysql',
'hostname' => '192.168.1.10',
'database' => 'shop_data_db_a',
'username' => 'root',
'password' => 'secret_a',
'charset' => 'utf8mb4',
],
'shop_b_db' => [
'type' => 'mysql',
'hostname' => '192.168.1.11',
'database' => 'shop_data_db_b',
'username' => 'root',
'password' => 'secret_b',
'charset' => 'utf8mb4',
],
],
];
这种配置适合数据库数量固定,且每个库的地址都肯写在项目里的情况。真实的SaaS系统可能会从配置中心拉取,不过原理相通:最终我们要得到“一个连接名”,再交给模型去查。
第二步:建立门店与连接的映射
我创建了一个服务类 `ShopDatabaseRouter`,专门负责根据shopId返回正确的连接名。它从主库的 `shop_confs` 表里读取店铺配置,然后缓存起来,避免每次请求都去查主库。
namespace appservice;
use thinkfacadesCache;
use thinkfacadesDb;
class ShopDatabaseRouter
{
public static function getConnectionByShopId(int $shopId): string
{
return Cache::remember('shop_db_' . $shopId, function () use ($shopId) {
$conf = Db::table('shop_confs')
->where('shop_id', $shopId)
->field('db_name, db_host')
->find();
if (!$conf) {
throw new InvalidArgumentException('店铺不存在或未配置数据库');
}
// 实际就是把映射关系读出来,返回一个连接标识
if ($conf['db_name'] === 'shop_data_db_a') {
return 'shop_a_db';
}
if ($conf['db_name'] === 'shop_data_db_b') {
return 'shop_b_db';
}
// 如果以后有新的库,只需要在这加判断,或者把连接名直接存到最后映射
throw new InvalidArgumentException('未找到该店铺对应的数据库');
}, 60);
}
}
这只是中间版本,因为如果数据库越来越多,这里同样会变成巨大的 if 列表。但比刚才好了一点:至少不用新建模型类,也不用在控制器里写一堆分支。映射判断统一收在一个服务里,其他地方不知道具体有多少个库。
更好的做法是直接把连接名存到 `shop_confs` 表里,这样新增店铺时就只需要在后台填一个连接名,代码完全不用动。这里为了省事,我简化成了 if 判断,但真实项目中请把 `connection_name` 字段建出来。
第三步:用Order::on()动态切换连接
接下来在控制器或者业务类中调用就非常简单了。
namespace appcontroller;
use appmodelOrder;
use appserviceShopDatabaseRouter;
use thinkresponseJson;
class ShopOrderController
{
public function index(int $shopId): Json
{
$connection = ShopDatabaseRouter::getConnectionByShopId($shopId);
$orders = Order::on($connection)
->where('status', 1)
->order('pay_time', 'desc')
->limit(20)
->select();
return json([
'shop_id' => $shopId,
'count' => count($orders),
'list' => $orders,
]);
}
}
这里 `Order::on()` 是 ThinkPHP 模型自带的跨连接查询方式。你不需要在 `Order` 模型里手写 `protected $connection = ‘shop_a_db’;`,因为那会把 `Order` 模型锁死在某一个库。只有当所有订单都在同一个固定库时,才适合在模型里加 $connection 属性。
第四步:如果使用Db查询构造器
有时候你只是临时取一条数据,并不想引入模型,那可以直接配合 `Db::connect()` 来使用。还是上面的需求:
use thinkfacadesDb;
$connection = ShopDatabaseRouter::getConnectionByShopId($shopId);
$orders = Db::connect($connection)
->table('order')
->where('status', 1)
->select();
注意,`Db::connect()` 后面链式调用的不是 `name()`,而是 `table()`。因为 `table()` 需要你主动写完整表名,`name()` 在多个连接下偶尔会有别名缓存问题,所以我建议跨库查询时都写全表名,不要太依赖think的默认表前缀。
第五步:把映射表做成动态,一劳永逸
如果你希望新增店铺时不用发布代码,可以在每个店铺的数据库记录里增加一个 `connection_name` 字段,然后上面 router 方法改成这样:
public static function getConnectionByShopId(int $shopId): string
{
return Cache::remember('shop_db_' . $shopId, function () use ($shopId) {
$conf = Db::table('shop_confs')
->where('shop_id', $shopId)
->value('connection_name');
if (empty($conf)) {
throw new InvalidArgumentException('店铺未配置数据库连接');
}
return $conf;
}, 60);
}
如此一来自动化程度就很高了。以后在后台给店铺配置一个 `connection_name`,前端传不同shopId就能打到不同的库里。真正代码里不再有新增店铺需要改 `switch` 的情况。
使用时的几个注意点
1. 主库连不上的时候要避免雪崩
上面的路由服务每次都从主库读 `shop_confs`,虽然加了缓存,但缓存失效时所有请求都会涌到主库。最好给ShopDatabaseRouter额外加一个不依赖数据库的本地映射,或者让缓存加锁。不过这个和跨库本身没多大关系,但值得提醒。
2. 小心分页查询的count
如果用模型分页,类似 `paginate()`,它会在底层自动执行两次查询,一次查count,一次查list。如果跨库且数据库本身比较大,建议单独优化分页的count条件。我这里因为只取20条,所以没调用分页。
3. 关联模型要特别注意连接名
如果你的订单模型里面又关联了其他模型,比如 `Order` 关联了 `OrderItem`,那么只用 `Order::on()` 只能保证主模型使用指定连接,关联模型并不会自动继承同一个连接。你需要在关联方法内部手动指定连接,或者让两个模型都动态设置关联连接。
举个最稳妥的关闭关联跨库坑的方法:
class Order extends thinkModel
{
protected $connection = 'mysql'; // 默认主库,实际查询时on会覆盖
public function items()
{
// 这里强制关联模型也使用相同路由
$connection = ShopDatabaseRouter::getConnectionByShopId($this->shop_id);
return $this->hasMany(OrderItem::class, 'order_id', 'id')
->remember(function ($builder) use ($connection) {
$builder->connection($connection);
});
}
}
实际上 ThinkPHP 的关联查询底层不会自动继承主模型连接,这个问题让不止一个开发者深夜崩溃。所以做跨库方案时,建议先手动实测一遍 `with(‘items’)` 是否能查到数据。如果没出来,就老老实实在关联写法里把连接名传进去。
4. 跨库不要试图做事务
有的客户场景是:要同时修改门店A库和门店B库里各一张表。如果这个操作需要原子性,你在PHP代码里分别调用两个库的事务是做不到的。因为A库连接和B库连接是两个独立连接,彼此没有全局协调。最简单的应对是:把需要事务的数据放到同一个库里,或者在业务上通过补偿接口解决。如果你真的想严格保证ACID,那应该引入分布式事务中间件,而不是靠数据库连接。
所以我在项目设计里明确约定:关联性很强的表必须放在同一个库。跨库只做查询展示用,不做跨库写入。
写一个更快乐的示例
下面这段代码,为你演示了如何用一个接口配合不同的店铺ID自动切库。假设路由是 `GET /order-list/:shop_id`,实际执行后你会发现 `shop_id=1` 时查到的是 shop_a_db 里的数据,`shop_id=2` 时查到的是 shop_b_db 里的数据。
namespace appcontroller;
use thinkfacadesDb;
use appserviceShopDatabaseRouter;
class OrderList
{
public function index($shop_id)
{
if ($shop_id < 1) {
return json(['error' => 'bad_shop_id']);
}
$connection = ShopDatabaseRouter::getConnectionByShopId($shop_id);
$list = Db::connect($connection)
->table('shop_order')
->where('order_status', 'paid')
->field('id, order_sn, total_amount, create_time')
->select()
->toArray();
return json([
'shop' => $shop_id,
'db' => $connection,
'size' => count($list),
'data' => $list,
]);
}
}
这样,控制器代码几乎不用再关注切换逻辑,如果你后续还要接新库进来,也只需要在数据库路由表中绑定一个新连接名,其它的代码一行都不用动。我愿称之为“动态连接映射表模式”。
最后说一句这次改造的感想
有些人可能会嫌自己多写一个 Router 类,还不如直接用 `if` 来的直白。但只要你经历过那段半夜被叫醒加店铺、然后在几十个case里找bug的日子,就会懂这种“查一张表得到连接名”的方案有多么香。
ThinkPHP 里并没有强制规定连接名称必须写在模型或配置文件里,它允许我们在运行时通过 `on()` 方法临时指定连接。这个特性让多库架构变得非常灵活,也使代码能够以很低的成本支撑快速增长的库数量。希望我的这个例子能给你带来一些启发。

