多租户 MySQL 隔离方案:代购系统的库表设计与索引治理
适合谁看:正在为代购/集运 SaaS 选型数据存储方案的后端开发与 DBA。
不适合可以跳过:单库单商户、没有租户概念的小型项目。
上一篇讨论状态机时提到,状态落在哪张表,已经决定了后三年的迁移成本。本文把存储问题摊开:当需要支撑大量独立站点时,数据应当如何放置,才能兼顾隔离性与查询性能。
仍以 taocarts 这类跨境代购与集运系统的建模思路为例,表名使用业务语义称呼,不暴露真实库表名。
1. 痛点对比:隔离性、成本与变更速度
多租户存储常见三条路线,取舍点各不相同:
| 方案 | 隔离性 | 成本 | 变更效率 | 一句话总结 |
|---|---|---|---|---|
| 共享库共享表 + tenant_id | 依赖开发纪律 | 最低 | 一次迁移影响全网 | 代码漏写 where 条件即串站 |
| 共享实例多 Schema | 中 | 中 | 中等 | 权限与备份粒度有所改善 |
| 一租户一库 | 强 | 连接数/备份数增加 | 可按站点灰度变更 | 故障影响面收敛到单站 |
代购站点的支付、物流、运费差异较大,一旦出错的代价是「串单/串用户」。这类业务中,一租户一库通常比「先共享表,以后再说」更划算——划算在风险控制,而非云账单金额。
2. 中央注册库与业务库分层
不建议将「站点列表」和「订单明细」放入同一个业务库。更清晰的分层是:
[中央注册库]
- 站点档案:域名、状态、套餐
- 连接信息:业务库 DSN(加密存储)
- 全局开关:维护窗口、功能开关
[业务库 × N]
- 用户/订单/包裹/轨迹/财务流水
- 仅包含本站数据
请求经域名解析拿到站点标识后,只打开对应业务库连接。报表汇总走离线任务,禁止业务 API「for 循环遍历每个库 select 一遍」作为常规操作。
3. 订单主数据建模:订单头、子单、包裹
一次代购很少是「一行订单」就能搞定的。更常见的拆分方式:
订单头(Order)
├─ 费用与支付意图(应付、已付、币种、锁价快照)
├─ 子单/采购行(Order Item)—— 每个商品链接/SKU 一行
└─ 包裹(Package)—— 仓内合包后的出库单位
└─ 物流运单(Shipment)—— 国际段单号与渠道
对应的逻辑模型如下(以可运行的 MySQL DDL 示意):
-- 订单头表
CREATE TABLE `order` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`user_id` BIGINT UNSIGNED NOT NULL,
`state` TINYINT NOT NULL COMMENT '状态机当前状态值',
`currency` CHAR(3) NOT NULL DEFAULT 'CNY',
`locked_fx_rate` DECIMAL(18, 8) DEFAULT NULL COMMENT '锁价快照',
`paid_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '实付金额,最小币种单位',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY `idx_user_state` (`user_id`, `state`),
KEY `idx_state_updated` (`state`, `updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 子单/采购行表
CREATE TABLE `order_item` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`order_id` BIGINT UNSIGNED NOT NULL,
`source_url` VARCHAR(512) NOT NULL,
`sku` VARCHAR(128) NOT NULL,
`qty` INT NOT NULL DEFAULT 1,
`purchase_state` TINYINT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_order` (`order_id`),
CONSTRAINT `fk_order_item_order` FOREIGN KEY (`order_id`) REFERENCES `order`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 包裹表
CREATE TABLE `package` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`user_id` BIGINT UNSIGNED NOT NULL,
`state` TINYINT NOT NULL,
`weight_grams` INT DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_user_state` (`user_id`, `state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 包裹-商品关联表
CREATE TABLE `package_item` (
`package_id` BIGINT UNSIGNED NOT NULL,
`order_item_id` BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (`package_id`, `order_item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 运单表
CREATE TABLE `shipment` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`package_id` BIGINT UNSIGNED NOT NULL,
`carrier` VARCHAR(64) NOT NULL,
`tracking_no` VARCHAR(128) NOT NULL,
UNIQUE KEY `uk_tracking` (`carrier`, `tracking_no`),
CONSTRAINT `fk_shipment_package` FOREIGN KEY (`package_id`) REFERENCES `package`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
设计要点:
- 行与头分离:采购失败可以只落在某一行上,不必让整单进入异常状态。
- 包裹是履约单位:合包后轨迹挂在包裹/运单上,不要只挂在商品行上导致一对多混乱。
- 状态字段分散存放:头状态、行状态、包裹状态拆开;用状态机约束流转,而不是多个字段随意修改。
4. 索引设计:优先覆盖高频查询
代购后台最常出现的查询类型:
- 按订单号
- 按用户
- 按国内快递单号(入仓扫描)
- 按国际运单号(客服查件)
- 按状态 + 时间(工作台队列)
对应的索引建议如下:
-- 按用户查询订单列表(后台常见)
ALTER TABLE `order` ADD INDEX `idx_user_created` (`user_id`, `created_at` DESC);
-- 客服按国际运单号查件:运单号独立索引即可
ALTER TABLE `shipment` ADD UNIQUE INDEX `uk_tracking_no` (`tracking_no`);
-- 状态队列查询:低基数列不宜单建索引,与时间列组合
ALTER TABLE `order` ADD INDEX `idx_state_updated` (`state`, `updated_at`);
原则:
- 等值查询优先使用独立索引或最左前缀命中。
- 状态类低基数列避免单独建索引,用
(state, updated_at)组合索引。 - 运单号全局唯一约束落在「站点库内唯一」即可;跨站已经分库,不需要强行设计一张全局唯一表。
- 警惕「所有筛选条件都建联合索引」——写入放大比慢查询更难处理。
慢查询出现时先看执行计划,再考虑分库分表。不少站点的量级,索引优化与冷热归档已经足够。
5. 汇率与锁价:资金相关表单独建模
跨境订单最容易产生纠纷的是:下单汇率与退款汇率发生在不同时间点。
建议至少设计两张表:
-- 汇率行情表:来自第三方或手动录入
CREATE TABLE `fx_rate` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`currency_pair` VARCHAR(16) NOT NULL COMMENT '如 USD/CNY',
`rate` DECIMAL(18, 8) NOT NULL,
`source` VARCHAR(64) NOT NULL DEFAULT 'manual',
`effective_at` DATETIME NOT NULL,
KEY `idx_pair_time` (`currency_pair`, `effective_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 订单锁价表:下单时写入快照
CREATE TABLE `fx_lock` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`order_id` BIGINT UNSIGNED NOT NULL,
`currency_pair` VARCHAR(16) NOT NULL,
`locked_rate` DECIMAL(18, 8) NOT NULL,
`locked_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
下单时把锁价快照写入订单头(或独立的 fx_lock 表),退款策略预先定义:按锁价、按退款日行情、或按规则取两者较差方——策略写入配置,数字存入快照。禁止在展示环节实时计算并修改历史订单金额。
金额字段使用整数最小币种单位或 DECIMAL 精确类型,禁止使用 float。
6. 扩展路线:读写分离与分表的时机
推荐的演进顺序:
- 先做:索引优化、历史订单归档、热点配置缓存
- 再做:只读副本承担报表与模糊搜索
- 然后:单站内大表按时间分表(如订单历史表)
- 最后:才考虑引入分布式中间件方案
一租户一库已经在租户维度完成了分片。大部分代购站点的瓶颈集中在「某几个大站的某几张热表」,而不是「所有小站的合计数」。优化资源应投向热站,而不是平均分配给所有站点。
7. 迁移与 DDL 规范化
独立库带来的红利是可以灰度:先迁 1 个站验证 DDL,再逐步滚动。
独立库带来的代价是 DDL 必须平台化:通过任务队列遍历站点连接执行变更,禁止人工逐个站点手工操作。
变更纪律:
- 兼容性发布:先加可空列 → 双写 → 切换读取 → 再删旧列
- 禁止在业务高峰期直接修改大表结构,需先评估 online DDL 支持能力
DDL 任务队列的简化示例(PHP 完整实现):
<?php
/**
* 站点 DDL 灰度迁移队列 - 简化版任务分发器
* 依赖:Redis 队列、PDO 扩展
*/
declare(strict_types=1);
class SiteDdlMigrator
{
private PDO $registryPdo;
private Redis $redis;
public function __construct(PDO $registryPdo, Redis $redis)
{
$this->registryPdo = $registryPdo;
$this->redis = $redis;
}
/**
* 从中央注册库读取全部业务库 DSN,推入待执行队列
*/
public function dispatch(string $ddl): int
{
$stmt = $this->registryPdo->query("SELECT id, dsn FROM site_registry WHERE status = 'active'");
$count = 0;
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$payload = json_encode([
'site_id' => (int)$row['id'],
'dsn' => $row['dsn'],
'ddl' => $ddl,
], JSON_UNESCAPED_UNICODE);
$this->redis->rPush('ddl_queue', $payload);
$count++;
}
return $count;
}
/**
* 消费队列:逐站执行 DDL,失败记录日志退出
*/
public function consume(): void
{
while ($payload = $this->redis->lPop('ddl_queue')) {
$job = json_decode($payload, true);
try {
$pdo = new PDO($job['dsn']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec($job['ddl']);
echo sprintf("site=%d DDL OK\n", $job['site_id']);
} catch (Throwable $e) {
echo sprintf("site=%d DDL FAIL: %s\n", $job['site_id'], $e->getMessage());
}
}
}
}
// 使用示例:
// $migrator = new SiteDdlMigrator($registryPdo, $redis);
// $migrator->dispatch("ALTER TABLE `order` ADD COLUMN `remark` VARCHAR(255) NULL DEFAULT NULL AFTER `paid_amount`");
// $migrator->consume();
8. 总结
多租户数据库设计,先选隔离方案,再谈性能优化。代购独立站场景中,一租户一库 + 中央注册库 + 订单/行/包裹分层 + 锁价快照是一套可长期演进的底子。分库分表是后手,不是第一天的勋章。
本系列《跨境代购系统技术实战》① 多租户隔离 → ② 订单状态机 → ③ 多租户 MySQL(本文)。后续预告:插件化支付/物流、集运合包算法。
标签:多租户 MySQL 订单建模 索引优化 汇率锁价 代购系统
- 点赞
- 收藏
- 关注作者
评论(0)