多租户 MySQL 隔离方案:代购系统的库表设计与索引治理

举报
云上老码农 发表于 2026/08/20 11:23:21 2026/08/20
【摘要】 适合谁看:正在为代购/集运 SaaS 选型数据存储方案的后端开发与 DBA。不适合可以跳过:单库单商户、没有租户概念的小型项目。上一篇讨论状态机时提到,状态落在哪张表,已经决定了后三年的迁移成本。本文把存储问题摊开:当需要支撑大量独立站点时,数据应当如何放置,才能兼顾隔离性与查询性能。仍以 taocarts 这类跨境代购与集运系统的建模思路为例,表名使用业务语义称呼,不暴露真实库表名。 1....

适合谁看:正在为代购/集运 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`);

原则:

  1. 等值查询优先使用独立索引或最左前缀命中。
  2. 状态类低基数列避免单独建索引,用 (state, updated_at) 组合索引。
  3. 运单号全局唯一约束落在「站点库内唯一」即可;跨站已经分库,不需要强行设计一张全局唯一表。
  4. 警惕「所有筛选条件都建联合索引」——写入放大比慢查询更难处理。

慢查询出现时先看执行计划,再考虑分库分表。不少站点的量级,索引优化与冷热归档已经足够。

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. 扩展路线:读写分离与分表的时机

推荐的演进顺序:

  1. 先做:索引优化、历史订单归档、热点配置缓存
  2. 再做:只读副本承担报表与模糊搜索
  3. 然后:单站内大表按时间分表(如订单历史表)
  4. 最后:才考虑引入分布式中间件方案

一租户一库已经在租户维度完成了分片。大部分代购站点的瓶颈集中在「某几个大站的某几张热表」,而不是「所有小站的合计数」。优化资源应投向热站,而不是平均分配给所有站点。

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 订单建模 索引优化 汇率锁价 代购系统

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。