跳到正文
Kale的数字花园
目录 文章
/26 分钟/9 阅读/0 评论

亿级商品可售数量自动更新系统

Creeks ERP 系统对接了 eBay、Shopify、TikTok Shop 等多个跨境电商平台,商品总量达**亿级**(其中 eBay 约 8000W)。各平台要求商品的可售数量(Available Quantity)与实际库存保持

背景与痛点

业务背景

Creeks ERP 系统对接了 eBay、Shopify、TikTok Shop 等多个跨境电商平台,商品总量达亿级(其中 eBay 约 8000W)。各平台要求商品的可售数量(Available Quantity)与实际库存保持同步,否则会导致:

  • 超卖:平台展示的可售数量 > 实际库存,客户下单后无法发货,引发平台处罚
  • 少卖:平台展示的可售数量 < 实际库存,错失销售机会

核心痛点

  1. 数据量巨大:亿级商品,每小时需要计算一次库存快照,全量计算不现实
  2. 产品→商品维度转换复杂:ERP 内部以产品(SKU)为维度管理库存,但平台以商品(Listing/Goods)为维度,一个 SKU 可能对应多个平台商品
  3. 多仓库多维度汇总:不同平台的库存计算规则不同——eBay 按国家汇总,Shopify 按销售渠道+逻辑仓,TikTok Shop 按物理仓+逻辑仓混合
  4. 库存类型复杂:不仅有常规可用库存,还有换发库存(产品替代关系)和组合库存(子产品组合关系,木桶原理取 MIN),计算逻辑涉及多表关联
  5. 平台 API 限速:各平台对库存更新接口有严格的调用频率限制(如 eBay 每 15 秒不超过 6000 次)
  6. 上报时效性要求:库存变化需要尽快同步到平台,但又不能因为频繁调用触发限流
  7. 各企业个性化规则:同一平台下不同企业的库存取数逻辑完全不同(如 Shopify 下酷风取逻辑仓、文悦取物理仓+FBA、新婕丽只取物理仓)

三平台可售数量规则对比

平台 取数来源 计算逻辑 上报粒度
eBay CN仓(壹拓云仓) + Winit仓物理库存 产品+国家维度汇总(常规+换发+组合) 按商品上报
Shopify 因企业而异:酷风/仙尼斯→逻辑仓;文悦→壹拓云仓+max(FBA-US);新婕丽→壹拓云仓 按产品规则表+销售渠道汇总 按商品上报
TikTok Shop CN仓+Winit仓;CN仓区分逻辑/物理库存 产品+仓库维度汇总;逻辑仓优先,物理仓补充 按商品+仓库上报

技术演进

eBay v1.0 版本基于库存变动日志触发计算,存在日志丢失和计算不准确的问题。v1.1 重构为基于 ClickHouse 快照的增量对比方案,从根本上解决了数据准确性问题,并为后续 Shopify、TikTok Shop 的接入奠定了架构基础。


技术方案

整体架构(三段式流水线)

┌──────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  MySQL (库存主库)  │     │                 │     │                 │
│  inv_inventory    │     │  Statistics 服务  │────▶│   Sales 服务     │────▶  Esync 服务
│  _collect         │     │  (库存快照计算)   │     │  (维度转换+Feed) │     │  (平台API上报)
└───────┬──────────┘     └─────────────────┘     └─────────────────┘
        │ MaterializedMySQL                           MySQL + ES              平台 API
        ▼ (实时同步引擎)
┌──────────────────┐
│  ClickHouse      │
│  creeks_inventory │
│  _repl           │
└──────────────────┘

数据底座:MaterializedMySQL 实时同步

库存主数据存储在 MySQL 的 inv_inventory_collect 表中。通过 ClickHouse 的 MaterializedMySQL 同步引擎,将 MySQL 的库存表、仓库表、逻辑库存表等实时映射到 ClickHouse 的 creeks_inventory_repl 库,作为快照计算的数据源。

这个设计的好处是:

  • 不侵入主库:快照计算的大量聚合查询全部在 ClickHouse 上执行,不影响 MySQL 主库性能
  • 数据实时性:MaterializedMySQL 基于 binlog 同步,延迟在秒级
  • 计算能力:ClickHouse 的列式存储和向量化引擎天然适合大规模 GROUP BY 聚合

第一阶段:库存快照生成与变化计算(Statistics 服务 + ClickHouse)

核心思路:每小时生成一份全量库存快照,通过与上一份快照对比,只提取发生变化的产品,大幅减少下游处理量。

快照生成流程(GenerateProductSnapshotTask):

1. 创建快照记录(MySQL,状态=UNCOMPLETED)
2. 生成货物级快照(ClickHouse INSERT...SELECT)
   ├── 常规货物快照(stock_inventory_snapshot)
   └── FBA 货物快照
3. 生成产品级快照(多维度汇总)
   ├── 按仓库 ID 汇总(product_inventory_snapshot_by_warehouse)
   ├── 按国家+仓库类型汇总(product_inventory_snapshot)—— eBay 使用
   ├── 壹拓云仓汇总(product_inventory_snapshot_by_warehouse_etop)—— Shopify 使用
   └── TikTok Shop 物理仓汇总(product_inventory_snapshot_by_warehouse_tiktokshop)
4. 生成逻辑仓快照
   ├── 逻辑仓货物快照
   ├── 逻辑仓产品快照
   ├── 销售渠道维度逻辑仓快照 —— Shopify 使用
   └── 销售渠道+物理仓维度快照 —— TikTok Shop 使用
5. 事务内:标记快照完成 + 创建各平台计算任务

库存变化计算(CalculateProductSnapshotChangeTask + 策略模式):

采用策略模式(ProductInventoryStrategyFactory),根据平台 ID 分发到不同的计算策略:

平台 策略类 计算逻辑
eBay EbayProductInventoryStrategy 当前快照 vs 上一份快照,HAVING 过滤无变化数据
Shopify ShopifyProductInventoryStrategy 多库存来源汇总(壹拓云仓+逻辑仓+FBA)
TikTok Shop TiktokShopProductInventoryStrategy 逻辑仓优先,LEFT ANTI JOIN 补充物理仓

eBay 变化计算 SQL 核心逻辑:

-- 当前快照和历史快照 UNION ALL 后按国家+产品分组
-- HAVING cur_available_quantity != before_available_quantity
-- 只保留发生变化的产品,8000W 商品中通常只有几十万产生变化
-- 注意:可用库存 = 常规可用 + 换发可用 + 组合换发可用
SELECT ... FROM (
    SELECT warehouse_country, product_id,
           SUM(available_quantity + exchange_quantity + combine_exchange_quantity) as cur_quantity,
           0 as before_quantity
    FROM product_inventory_snapshot WHERE snapshot_id = ${curSnapshotId} AND warehouse_type IN (1,2)
    GROUP BY warehouse_country, product_id
    UNION ALL
    SELECT warehouse_country, product_id, 0 as cur_quantity, SUM(...) as before_quantity
    FROM product_inventory_snapshot WHERE snapshot_id = ${beforeSnapshotId} AND warehouse_type IN (1,2)
    GROUP BY warehouse_country, product_id
) GROUP BY warehouse_country, product_id
HAVING cur_available_quantity != before_available_quantity

换发库存与组合库存的计算(eBay/Shopify 共用):

这是整个库存计算中最复杂的业务逻辑,涉及产品之间的替代和组合关系:

产品可用库存 = 常规可用 + 换发可用 + 组合可用

1. 常规可用:直接按产品维度汇总壹拓云仓的物理库存
2. 换发可用:产品A的换发产品是B → 取B的库存作为A的换发可用
   - 通过 pms_exchange_sku_relation 表维护换发关系
   - 需要先生成换发关系快照(避免计算过程中关系变更)
3. 组合可用(木桶原理):
   - 组合产品由多个子产品组成,每个子产品有组合数量
   - 每个子产品的可组合数 = FLOOR(子产品库存 / 组合数量)
   - 组合可用 = MIN(所有子产品的可组合数)
   - 例:组合产品X由A×2+B×3组成,A库存10,B库存9
         → A可组合=FLOOR(10/2)=5,B可组合=FLOOR(9/3)=3
         → 组合可用=MIN(5,3)=3

对应的组合库存 SQL:

-- 木桶原理:取所有子产品可组合数的最小值
SELECT combo_sku_id, MIN(combine_available_quantity) as combine_quantity
FROM (
    SELECT t1.combo_sku_id,
           toInt64(FLOOR(ifnull(t2.quantity, 0) / t1.sub_sku_count)) as combine_available_quantity
    FROM creekson_repl.pms_combo_sku_relation t1
    LEFT JOIN (
        SELECT product_id, sum(available_quantity) as quantity
        FROM product_inventory_snapshot_by_warehouse
        WHERE snapshot_id = ${snapshotId} AND warehouse_type = 1 AND warehouse_self_type = 3
        GROUP BY product_id
    ) t2 ON t1.sub_sku_id = t2.product_id
) GROUP BY combo_sku_id

Shopify 产品规则表设计与方案选型:

Shopify 的核心难点在于:不同企业的库存取数逻辑完全不同,甚至同一企业内不同产品的规则也不同。

企业 库存规则 说明
酷风/仙尼斯 规则2:逻辑仓库存 按销售渠道下的逻辑仓汇总
文悦 规则1+规则3:壹拓云仓常规+max(FBA-US) 物理库存+FBA仓取最大值
新婕丽 规则1:壹拓云仓常规库存 纯物理库存
(后续扩展) 规则4:壹拓云仓换发库存 换发产品的库存
(后续扩展) 规则5:壹拓云仓组合库存 组合产品的库存

为此设计了 shopify_product_inventory_rule 表(ClickHouse),记录每个产品应该使用哪种规则计算。

方案选型过程:

  • 方案一:将 Shopify 商品信息同步到 ClickHouse,通过 SQL 直接生成规则表 → 最终选择
  • 方案二:分页查询 MySQL 商品信息,在内存中生成规则表
  • 选择方案一的原因:后续可能还有其他需要将商品同步到 ClickHouse 的需求,一次投入长期复用

最终的 Shopify 库存计算 SQL 将三种来源 UNION ALL 后按产品+销售渠道汇总:

SELECT product_id, sale_channel, sum(quantity) FROM (
    -- 壹拓云仓(规则1/4/5 的产品)
    SELECT product_id, 0 as sale_channel, quantity FROM shopify_tmp
    UNION ALL
    -- 逻辑仓(规则2 的产品)
    SELECT t1.product_id, t1.sales_channel_id, sum(t2.available_quantity)
    FROM shopify_product_inventory_rule t1
    LEFT JOIN product_logic_inventory_snapshot_by_sales_channel t2 ...
    WHERE t1.type = 2
    UNION ALL
    -- FBA(规则3 的产品,取 max)
    SELECT t1.product_id, 0, ifnull(max(t.available_quantity), 0)
    FROM shopify_product_inventory_rule t1
    LEFT JOIN product_inventory_snapshot_by_warehouse t ...
    WHERE t1.type = 3 AND warehouse_type = 3 AND warehouse_country = 'US'
) GROUP BY product_id, sale_channel

TikTok Shop 的 LEFT ANTI JOIN 技巧:

TikTok Shop 的库存取数规则:

  • Winit仓(海外仓):直接取物理库存
  • CN仓(开启逻辑库存):取对应销售渠道下所属物理仓的逻辑仓库存
  • CN仓(未开启逻辑库存):取物理仓库存

核心难点在于:同一个 CN 仓,有的企业开启了逻辑库存,有的没有。需要在 SQL 层面实现"逻辑仓优先,物理仓补充"的混合取数:

-- ClickHouse 非 Nullable 整型默认值为 0,导致 LEFT JOIN + IS NULL 永远为 false
-- 使用 LEFT ANTI JOIN 替代,正确排除已有逻辑仓数据的产品
SELECT ... FROM (
    -- 逻辑库存(CN仓且开启逻辑库存)
    SELECT warehouse_id, product_id, sales_channel_id, available_quantity
    FROM product_logic_inventory_snapshot_channel_physical_warehouse
    WHERE snapshot_id = ${curSnapshotId}
    UNION ALL
    -- 物理库存(Winit仓 + CN仓未开启逻辑库存)
    SELECT t1.warehouse_id, t1.product_id, 0 AS sales_channel_id,
           (t1.available_quantity + t1.exchange_quantity + t1.combine_quantity)
    FROM product_inventory_snapshot_by_warehouse_tiktokshop t1
    LEFT ANTI JOIN (
        SELECT DISTINCT warehouse_id, product_id
        FROM product_logic_inventory_snapshot_channel_physical_warehouse
        WHERE snapshot_id = ${curSnapshotId}
    ) t2 ON t1.warehouse_id = t2.warehouse_id AND t1.product_id = t2.product_id
    WHERE t1.snapshot_id = ${curSnapshotId}
) GROUP BY warehouse_id, product_id, sales_channel_id

TikTok Shop 与 eBay/Shopify 的关键差异——按商品+仓库上报:

eBay 和 Shopify 都是按商品维度上报(一个商品一个可售数量),但 TikTok Shop 是按商品+仓库维度上报。这意味着:

  • 一个商品可能关联多个仓库,每个仓库有独立的可售数量
  • Feed 表需要支持 (goodsId, platformWarehouseId) 复合唯一键
  • 上报 API 需要构建三层嵌套结构:Product → SKU → Warehouse

这个差异导致 TikTok Shop 的 Sales 服务处理逻辑无法复用 BaseGoodsInventoryHandleTask,必须独立实现。

第二阶段:产品→商品维度转换与 Feed 生成(Sales 服务)

核心问题:Statistics 服务计算出的是产品维度(SKU)的库存变化,但平台上报需要的是商品维度(Listing/Goods)。一个 SKU 可能对应多个平台商品。

处理流程(以 eBay 为例,EbayGoodsInventoryHandleTask):

1. 获取待处理任务(PENDING_UPDATE 状态)
2. 分页查询产品库存变化(每页 500 条,游标翻页)
3. 按国家分组(避免跨国家查询导致笛卡尔积)
4. 通过 ES 查询对应商品(过滤条件:在售+已认领+一口价+下架时间范围)
5. 按 SKU 分组匹配:产品变化 → 对应商品列表
6. 加分布式锁(商品粒度,60s 超时)
7. 事务内生成:
   ├── GoodsInventorySummary(商品库存汇总)
   └── GoodsInventorySummaryFeed(上报记录,含优先级)
8. 标记任务完成

关键设计点:

  • ES 翻页优化:eBay 使用 Scroll API,Shopify/TikTok Shop 使用 SearchAfter(避免 Scroll 超时)
  • 优先级机制:Feed 按库存标签设置上报优先级(OUT_OF_STOCK > LOW_STOCK > REPLENISH > 普通)
  • 可售数量上限:reportingQuantity = min(availableQuantity, upperLimit),防止超卖
  • 上报豁免:部分商品可配置为不上报(mall_goods_reporting_exemption 表),用户在页面选中商品标记

白名单灰度控制机制:

为了安全上线,设计了两层白名单控制:

  1. eBay:支持 Listing(ASIN)维度和企业+店铺维度的白名单,存储在 Redis Set 中
    • 如果白名单为空 → 不做控制,全量上报
    • 如果 ASIN 白名单命中 → 直接放行
    • 如果企业+店铺白名单不为空 → 按店铺过滤
    • 两种白名单不会同时存在于同一企业
  2. Shopify:企业+店铺维度白名单
    • 配置方式:sadd 'shopify:goods:inventory:reporting:whitelist:{enterpriseId}' {storeId...}
    • 例:酷风企业有两个销售渠道(Shopify-COOTRY 和 Shopify-Ritera),只需为 Ritera 渠道上报
  3. TikTok Shop:企业+店铺维度白名单,逻辑同 Shopify

TikTok Shop 的独立实现(TiktokShopGoodsInventoryHandleTask):

TikTok Shop 不继承 BaseGoodsInventoryHandleTask,因为其商品与仓库是多对多关系(mall_goods_warehouse),需要独立的仓库映射链路:

商品 → 平台仓库ID → 发货仓ID → Creeks仓库ID → (物理仓/逻辑仓判断) → 分组处理

每页商品执行 3 次批量 RPC 构建映射,按 (warehouseId, salesChannelId) 分组后独立处理。

第三阶段:平台 API 上报(Esync 服务)

三平台上报策略对比:

维度 eBay Shopify TikTok Shop
数据量 ~8000W 商品 较少 中等
线程池 50核/75最大/队列300 无(同步+重试) 5核/5最大/队列200
批量大小 4个/批(API限制) 逐个调用 逐个调用
重试策略 批量失败→单个降级 5次指数退避(800ms×2) 2次线性退避(1s)
限速方式 线程池+分区控制 请求间隔100ms 线程池限制5并发
API限制 15秒内≤6000次/卖家 GraphQL限流 官方接口限流

eBay 多线程上报优化(ReviseInventoryServiceImpl):

这是整个系统中最复杂的上报实现,核心设计:

// 1. 按 4 个一组分区(eBay API 单次最多修改 4 个商品)
List<List<ReportingInventoryItem>> partitions = ListUtils.partition(itemList, 4);

// 2. CompletableFuture + 自定义线程池并发处理
ConcurrentLinkedQueue<ReportingInventoryItem> successQueue = new ConcurrentLinkedQueue<>();
ConcurrentLinkedQueue<ReportingInventoryItem> failQueue = new ConcurrentLinkedQueue<>();
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (List<ReportingInventoryItem> items : partitions) {
    futures.add(CompletableFuture.runAsync(
        () -> handleReportInventory(successQueue, failQueue, apiContext, items),
        ebayInventoryReportingExecutor));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get();

// 3. 异常降级:4个一起失败 → 逐个重试
try {
    call.reviseInventoryStatus(); // 4个一起上报
    successList.addAll(items);
} catch (Exception e) {
    for (ReportingInventoryItem item : items) {
        // 单个重试
        try { call.reviseInventoryStatus(); successList.add(item); }
        catch (Exception ex) { item.setMessage(ex.getMessage()); failList.add(item); }
    }
}

上报任务调度(EbayGoodsReportingInventoryTask):

  • 按优先级取前 20 个店铺,每个店铺异步处理
  • 单店铺最多处理 1000 条 Feed,避免阻塞影响并发度
  • 每批 100 条,加商品粒度分布式锁
  • Token 获取支持重试(3次,指数退避 2s→4s→8s)

实现细节与踩坑点

1. ClickHouse 非 Nullable 整型的坑

ClickHouse 的非 Nullable 整型字段默认值为 0(不是 NULL),导致 LEFT JOIN + IS NULL 永远为 false。TikTok Shop 的逻辑仓/物理仓混合查询中,使用 LEFT ANTI JOIN 替代,正确排除已有逻辑仓数据的产品。

2. ES Scroll 超时问题

eBay 商品量大,处理单批产品变化时需要翻页查询 ES 中的商品。由于上报任务并行执行存在锁等待,处理时间过长会导致 Scroll ID 过期。Shopify 和 TikTok Shop 改用 SearchAfter 翻页,避免此问题。

3. 分布式锁的多层设计

任务级锁(Redis):防止同一任务被多个实例重复执行
  └── 商品级锁(Redis,60s超时):防止库存计算与上报任务并发修改同一商品
       └── 店铺级锁(Redis):防止同一店铺的上报任务并发执行

4. eBay 按国家分组的必要性

如果不按国家分组查询商品,会出现笛卡尔积:查询 (sku1,DE) 和 (sku2,US) 时,ES 会返回 (sku1,DE)、(sku1,US)、(sku2,DE)、(sku2,US),导致错误的库存分配。

5. Shopify 更新时间窗口设计

Shopify 不是每小时都更新,而是选择在配货完成后的时间点(工作日 10:00/16:00,周六 12:00),此时库存大概率不会再有扣减,减少超卖风险。

6. 快照清理策略

保留 7-8 天的快照数据,通过 ALTER TABLE ... DROP PARTITION 高效清理 ClickHouse 分区数据,避免数据无限膨胀。

7. TikTok Shop 接入引发的 ES 索引重建(400W 数据迁移)

TikTok Shop 引入了"按商品+仓库上报"的模型,导致 GoodsInventoryDoc ES 文档结构发生变更:

  • @Id 语义从 goods.id 变更为 mall_goods_inventory_summary.id(summaryId)
  • 新增 goodsId、platformWarehouseId 字段

旧文档的 _id = goodsId,与新结构不兼容,无法通过增量更新修复,必须删除旧索引后全量重建。

重建方案:采用「按平台分治 + Maxwell CDC Bootstrap 分批重放」策略:

  • eBay(~290W 条):按 available_quantity_updated_at 时间段分 6 批,每批控制在 ~50W 条,避免 ES 写入压力过高和 consumer lag 堆积
  • Shopify(~2.8W 条):全量 bootstrap
  • TikTok Shop:全量 bootstrap

每批执行后观察 Kafka consumer lag 降为 0 后再执行下一批,确保数据完整性。

8. Shopify 产品规则表的方案选型

生成产品规则表有两种方案:

  • 方案一:将 Shopify 商品同步到 ClickHouse,通过 SQL 生成规则表
  • 方案二:分页查询 MySQL 商品信息,在内存中生成规则表

最终选择方案一。虽然需要额外实现 MySQL→ClickHouse 的同步,但后续其他需求也可能需要在 ClickHouse 中查询商品数据,一次投入长期复用。

9. eBay GTC 商品的下架时间处理

eBay 一口价商品为 GTC(Good 'Til Cancelled),每 30 天自动循环上线。查询在售商品时,下架时间需要向后推 32 天(DateUtil.addDay(now, 32)),避免刚刚更新下架时间的商品被错误过滤。同时向前推 3 天(DateUtil.addDay(now, -3)),避免商品信息未及时同步。


成果与数据

  • 覆盖 3 个主流跨境电商平台(eBay、Shopify、TikTok Shop),支撑亿级商品的可售数量自动同步
  • eBay 8000W 商品通过增量快照对比,每小时实际处理量降至几十万级,计算耗时从不可行降至分钟级
  • eBay 多线程上报优化:50 线程并发 + 4 个/批分区 + 异常降级,单店铺千级商品上报耗时控制在秒级
  • 通过优先级调度(OUT_OF_STOCK 优先),确保缺货商品最先同步,降低超卖率
  • 白名单+豁免机制支持灰度上线,零事故完成三个平台的逐步接入

技术亮点提炼

亮点一:基于 ClickHouse 的增量快照对比方案

面对 8000W 级商品,没有采用全量推送,而是设计了"快照生成→增量对比→变化提取"的三步流水线。利用 ClickHouse 的列式存储和向量化计算能力,通过 UNION ALL + GROUP BY + HAVING 在数据库层面完成增量计算,将处理量从亿级降至万级。

亮点二:MaterializedMySQL + ClickHouse 的计算架构

通过 ClickHouse 的 MaterializedMySQL 引擎将 MySQL 库存表实时同步到 ClickHouse,实现了"主库负责 OLTP,ClickHouse 负责 OLAP"的读写分离。所有大规模聚合计算(快照生成、库存汇总、变化对比)都在 ClickHouse 上执行,完全不影响 MySQL 主库性能。

亮点三:换发/组合库存的复杂计算模型

不仅计算常规可用库存,还实现了换发库存(产品替代关系)和组合库存(木桶原理取 MIN)的计算。组合库存需要关联子产品关系表,计算每个子产品的可组合数后取最小值,涉及多表 JOIN + 聚合 + 数学运算,全部在 ClickHouse SQL 层面完成。

亮点四:策略模式 + 产品规则表的多平台适配

三个平台的库存计算规则完全不同,通过策略模式统一调度。Shopify 更进一步,设计了产品规则表(5 种规则类型),支持同一平台下不同企业、不同产品使用不同的库存取数逻辑,具备良好的扩展性。

亮点五:eBay 多线程上报 + 异常降级

针对 eBay 8000W 商品的上报压力,设计了 CompletableFuture + 自定义线程池(50核/75最大)的并发方案,结合 eBay API 的 4 个/批限制进行分区。批量失败时自动降级为单个重试,最大化上报成功率。每个线程创建独立的 API Call 实例,避免线程安全问题。

亮点六:三层分布式锁 + 白名单灰度机制

任务级→商品级→店铺级的三层锁设计,保证并发安全。配合 Redis Set 实现的多维度白名单(Listing 维度、企业+店铺维度),支持按企业、按店铺、按商品粒度灰度上线,三个平台零事故逐步接入。

亮点七:跨服务流水线解耦 + 配套数据迁移

Statistics(计算)→ Sales(转换)→ Esync(上报)三个服务通过任务状态机解耦。TikTok Shop 接入时引发的 ES 索引结构变更,设计了 Maxwell CDC Bootstrap 分批重放方案,400W 数据按平台分治、按时间分批,平滑完成迁移。


项目难点分析

难点一:亿级数据的实时性与准确性平衡

  • 挑战:8000W eBay 商品全量计算不现实,但库存变化需要及时同步
  • 解法:增量快照对比,只处理变化数据;配合优先级调度,缺货商品优先上报
  • 权衡:牺牲了一定的实时性(小时级),换取了系统的可行性和稳定性

难点二:多平台异构库存模型的统一抽象

  • 挑战:eBay 按国家汇总、Shopify 有 5 种库存来源(壹拓常规/换发/组合+逻辑仓+FBA)、TikTok Shop 多对多仓库关系且上报粒度不同(商品+仓库)
  • 解法:统一的快照→变化→Feed 流水线,通过策略模式隔离差异;Shopify 设计产品规则表支持企业级个性化;TikTok Shop 因模型差异过大,独立实现处理逻辑
  • 权衡:TikTok Shop 没有强行复用基类,代码有一定重复,但换来了清晰的业务语义

难点三:换发/组合库存的业务复杂度

  • 挑战:产品之间存在换发替代关系和组合关系,库存计算不是简单的 SUM,而是涉及关系查找、快照固化、木桶原理等复杂逻辑
  • 解法:换发关系先生成快照(避免计算过程中关系变更导致数据不一致),组合库存用 FLOOR + MIN 实现木桶原理,全部在 ClickHouse SQL 层面完成,避免应用层大量数据传输
  • 关键细节:换发关系快照必须在计算前生成,否则如果计算过程中换发关系发生变更,会导致同一批快照内数据不一致

难点四:平台 API 限速下的高效上报

  • 挑战:eBay 15秒/6000次限制,Shopify GraphQL 限流,TikTok Shop 接口限流
  • 解法:eBay 用线程池控制并发度 + 分区批量 + 异常降级;Shopify 用请求间隔 100ms + 指数退避重试;TikTok Shop 用 5 线程限流
  • 权衡:不同平台采用不同的限速策略,而非统一方案,因为各平台的限流规则和 API 特性差异很大

难点五:跨服务数据一致性与平滑迁移

  • 挑战:快照计算(Statistics)、维度转换(Sales)、API 上报(Esync)跨三个服务,任何环节失败都可能导致数据不一致;TikTok Shop 接入时 ES 索引结构变更需要 400W 数据迁移
  • 解法:任务状态机 + 分布式锁 + 幂等设计;ES 迁移采用 Maxwell CDC Bootstrap 分批重放,eBay 按时间段分 6 批(每批~50W),每批完成后观察 consumer lag 再执行下一批
  • 关键细节:重建期间新增/修改的数据通过 CDC 实时消费保证不丢失,但前端查询可能暂时返回不完整数据,需要提前通知业务方

面试题与回答指南

Q1:请介绍一下你做的可售数量自动更新系统的整体架构?

回答思路:先说业务背景(为什么要做),再说整体架构(三段式流水线),最后点出核心挑战。

参考回答:

我们的 ERP 系统对接了 eBay、Shopify、TikTok Shop 等多个跨境电商平台,商品总量达亿级。核心需求是将 ERP 内部的库存变化自动同步到各平台,避免超卖和少卖。

整体架构是一个三段式流水线:

  1. Statistics 服务负责基于 ClickHouse 每小时生成库存快照,通过与上一份快照对比提取增量变化
  2. Sales 服务负责将产品维度(SKU)的库存变化转换为商品维度(Listing),生成上报记录
  3. Esync 服务负责调用各平台 API 完成实际上报

三个服务通过任务状态机解耦,每个环节可独立扩缩容。核心挑战是 eBay 8000W 商品的增量计算和高效上报。


Q2:eBay 8000W 商品,你是怎么做到高效计算库存变化的?

回答思路:强调"增量"思想,说清楚 ClickHouse 的 SQL 设计。

参考回答:

全量推送 8000W 商品是不现实的,所以我设计了增量快照对比方案。

每小时生成一份全量库存快照存入 ClickHouse,然后通过 SQL 将当前快照和上一份快照做 UNION ALL,按国家+产品分组后用 HAVING 过滤掉库存没有变化的产品。这样 8000W 商品中,通常只有几十万产生了变化,下游只需要处理这几十万条数据。

选择 ClickHouse 是因为它的列式存储和向量化计算非常适合这种大规模聚合计算场景,亿级数据的快照生成和对比都能在分钟级完成。

追问:为什么用 UNION ALL 而不是 JOIN?

因为两份快照中可能存在一方有而另一方没有的产品(新增或删除),用 JOIN 会丢失这部分数据。UNION ALL 后再 GROUP BY,可以正确处理新增(before=0)和删除(cur=0)的情况。


Q3:三个平台的库存计算规则不同,你是怎么处理的?

回答思路:说策略模式,然后举例说明差异。

参考回答:

三个平台的库存计算规则差异很大:

  • eBay 按国家汇总,需要对比前后两份快照
  • Shopify 有 5 种库存来源(壹拓云仓常规/换发/组合库存 + 逻辑仓 + FBA),按销售渠道分组
  • TikTok Shop 是逻辑仓优先、物理仓补充的混合模式

我用策略模式来隔离差异。ProductInventoryStrategyFactory 根据平台 ID 返回对应的策略实现,每个策略独立实现 calculate() 方法。新增平台只需要新增一个策略类,不影响现有逻辑。

TikTok Shop 在 Sales 服务层面也没有复用基类,因为它的商品-仓库是多对多关系,处理逻辑和 eBay/Shopify 差异太大,强行复用反而增加复杂度。


Q4:eBay 的多线程上报是怎么设计的?为什么要这样设计?

回答思路:从 API 限制出发,说线程池配置、分区策略、异常降级。

参考回答:

eBay 的 ReviseInventoryStatus API 有两个限制:单次最多修改 4 个商品,每个卖家 15 秒内不超过 6000 次调用。

基于这些限制,我的设计是:

  1. 先把商品列表按 4 个一组分区
  2. 用 CompletableFuture + 自定义线程池(50核心/75最大/队列300)并发处理每个分区
  3. 用 ConcurrentLinkedQueue 收集成功/失败结果,保证线程安全
  4. 关键的异常降级:如果 4 个一起上报失败,自动降级为逐个上报,最大化成功率

线程池用 CallerRunsPolicy 作为拒绝策略,队列满时由调用线程执行,起到天然的限流作用。50 个核心线程配合 4 个/批的分区,理论上 15 秒内的调用量在可控范围内。

追问:为什么每个任务要创建独立的 ReviseInventoryStatusCall 实例?

因为 eBay SDK 的 Call 对象不是线程安全的,内部持有状态。多线程共享同一个 Call 实例会导致数据错乱,所以每个任务必须创建独立实例。


Q5:你提到用了 ClickHouse 的 LEFT ANTI JOIN,能详细说说吗?

回答思路:先说问题(ClickHouse 的 Nullable 特性),再说解法。

参考回答:

TikTok Shop 的库存计算需要混合逻辑仓和物理仓数据:优先使用逻辑仓,如果某个产品在逻辑仓中没有数据,才使用物理仓。

最初我用 LEFT JOIN + WHERE t2.product_id IS NULL 来排除已有逻辑仓数据的产品,但发现结果不对。排查后发现 ClickHouse 的非 Nullable 整型字段默认值是 0 而不是 NULL,所以 IS NULL 永远为 false。

解决方案是使用 ClickHouse 特有的 LEFT ANTI JOIN,它的语义就是"左表中在右表没有匹配的行",不依赖 NULL 判断,完美解决了这个问题。


Q6:跨三个服务的数据一致性是怎么保证的?

回答思路:说任务状态机 + 分布式锁 + 幂等。

参考回答:

三个层面保证:

  1. 任务状态机:每个计算任务有明确的状态流转(UNCOMPLETED→PENDING_UPDATE→COMPLETED/FAILED),状态变更在事务内完成。任何环节失败,任务停留在当前状态,下次调度会重新处理。
  2. 分布式锁:三层锁设计——任务级锁防止重复执行,商品级锁防止计算和上报并发修改同一商品,店铺级锁防止同一店铺并发上报。
  3. 幂等设计:Feed 的保存使用 saveOrUpdate(按 goodsId 或 goodsId+warehouseId 作为唯一键),重复执行不会产生脏数据。上报成功后更新 Feed 状态,即使重复上报也不会有业务影响。

Q7:Shopify 为什么选择特定时间点更新,而不是每小时都更新?

回答思路:从业务角度解释。

参考回答:

这是一个业务驱动的设计决策。Shopify 的更新时间选在工作日 10:00 和 16:00,周六 12:00,周日不更新。

原因是这些时间点通常是配货完成后的时间,此时库存大概率不会再有大的扣减。如果在配货过程中更新可售数量,可能出现"刚上报了 100 个可售,结果配货扣了 50 个"的情况,增加超卖风险。

这也体现了一个权衡:不是技术上做不到实时更新,而是从业务角度看,选择合适的时间窗口更新反而能降低超卖率。


Q8:如果让你重新设计这个系统,你会做哪些改进?

回答思路:展示反思能力和技术视野。

参考回答:

几个方向:

  1. 实时化:目前是小时级的定时快照,可以考虑基于 CDC(Change Data Capture)监听库存变更事件,实现准实时的增量推送,进一步降低延迟。
  2. 统一上报网关:目前三个平台的上报逻辑分散在各自的 Esync 服务中,可以抽象一个统一的上报网关,封装限流、重试、熔断等通用能力,新增平台时只需实现 API 适配层。
  3. 可观测性:增加更完善的监控指标,比如每小时的变化量趋势、上报成功率、各平台的 API 延迟分布等,便于及时发现异常。
  4. TikTok Shop 的 RPC 优化:目前每页商品需要 3 次 RPC 构建仓库映射,可以考虑在本地缓存仓库映射关系,减少 RPC 调用。

Q9:你是怎么处理上报失败的情况的?

回答思路:说重试机制和降级策略。

参考回答:

分几个层面:

  1. API 层重试:各平台都有 @Retryable 注解配置。eBay 2次/1秒延迟,Shopify 5次/指数退避,TikTok Shop 2次/1秒延迟。
  2. 批量降级:eBay 特有的设计——4 个商品一起上报失败时,自动降级为逐个上报。因为可能只是其中一个商品有问题(比如已下架),不应该影响其他 3 个。
  3. Feed 重试任务:有专门的 RetryTask(如 EbayGoodsReportingRetryTask),定期扫描失败的 Feed,检查商品是否仍在售,如果在售则标记为待重试状态,等待下次上报任务处理。
  4. 失败记录:所有失败都会记录失败原因到 Feed 的 failMsg 字段,便于排查和统计。

Q10:这个系统的性能瓶颈在哪里?你做了哪些优化?

回答思路:从数据流的每个环节分析瓶颈和优化。

参考回答:

主要瓶颈和对应优化:

  1. ClickHouse 快照生成:亿级数据的 INSERT...SELECT 是最耗时的环节。优化方式是按维度拆分多个快照表,避免单表过大;使用分区表,清理时直接 DROP PARTITION。
  2. ES 商品查询:eBay 按国家分组查询避免笛卡尔积;使用 SearchAfter 替代 Scroll 避免超时;每批限制 1000 条,控制处理时间在 20-30 秒内。
  3. 上报并发度:eBay 用 50 线程池 + 4 个/批分区;上报任务按店铺维度并行,单店铺限制 1000 条避免阻塞。
  4. 锁竞争:商品级细粒度锁替代全局锁,减少锁等待;锁超时 60 秒,超时后抛异常等待下次调度,避免死锁。

Q11:换发库存和组合库存是怎么计算的?为什么要这样设计?

回答思路:先解释业务概念,再说技术实现。

参考回答:

这是跨境电商特有的业务场景:

换发库存:产品 A 和产品 B 是换发关系(可以互相替代发货)。如果客户买了 A 但 A 缺货,可以用 B 发货。所以 A 的可售数量应该包含 B 的库存。实现上通过 pms_exchange_sku_relation 表维护换发关系,计算时 JOIN 这张表找到换发产品的库存。

组合库存:组合产品 X 由子产品 A×2 + B×3 组成。X 的可售数量取决于最短板——即木桶原理。每个子产品的可组合数 = FLOOR(子产品库存 / 组合数量),然后取 MIN。比如 A 库存 10、B 库存 9,则 A 可组合 5、B 可组合 3,组合可用 = 3。

关键细节是:换发关系在计算前需要先生成快照,因为如果计算过程中换发关系发生变更,会导致同一批快照内数据不一致。


Q12:Shopify 的产品规则表是怎么设计的?为什么需要这个表?

回答思路:从业务需求出发,说明个性化规则的必要性。

参考回答:

Shopify 的核心难点是:不同企业的库存取数逻辑完全不同。酷风取逻辑仓库存,文悦取壹拓云仓物理库存加上 FBA 仓最大值,新婕丽只取壹拓云仓物理库存。甚至未来同一企业内不同产品的规则也可能不同。

为此我设计了 shopify_product_inventory_rule 表,存储在 ClickHouse 中,记录每个产品应该使用哪种规则。目前定义了 5 种规则类型:壹拓云仓常规、逻辑仓、max(FBA-US)、壹拓换发、壹拓组合。

计算时,先根据企业配置初始化规则表,然后在最终的汇总 SQL 中,按规则类型分别 JOIN 不同的快照表,UNION ALL 后按产品+销售渠道汇总。这样新增企业或新增规则只需要配置,不需要改代码。

追问:规则表为什么放在 ClickHouse 而不是 MySQL?

因为规则表需要和 ClickHouse 中的快照表做 JOIN 计算。如果放在 MySQL,就需要把快照数据拉到应用层再匹配,数据量太大不现实。放在 ClickHouse 可以在 SQL 层面直接完成 JOIN + 聚合,效率高得多。

生成规则表时也有两种方案:一是将 Shopify 商品同步到 ClickHouse 通过 SQL 生成,二是分页查 MySQL 在内存中生成。我选了方案一,因为后续其他需求也可能需要在 ClickHouse 中查询商品数据。


Q13:TikTok Shop 的仓库映射链路为什么这么复杂?

回答思路:解释 TikTok Shop 的多对多关系和三次 RPC 的必要性。

参考回答:

TikTok Shop 和 eBay/Shopify 最大的区别是:商品与仓库是多对多关系,一个商品可以从多个仓库发货,每个仓库有独立的可售数量。

但 TikTok Shop 平台的仓库 ID 和我们 ERP 内部的仓库 ID 不是同一套体系,中间有一层"发货仓"的映射。所以需要三次 RPC 构建映射链路:

  1. 商品 → 平台仓库 ID(mall_goods_warehouse 表)
  2. 平台仓库 ID → 发货仓 ID(store_shipping_warehouse 表,按 accountId 过滤)
  3. 发货仓 ID → Creeks 内部仓库 ID(store_warehouse_relation 表)

拿到内部仓库 ID 后,还需要判断这个仓库是走物理库存还是逻辑库存(取决于企业是否开启了逻辑库存白名单)。

这也是 TikTok Shop 没有复用 BaseGoodsInventoryHandleTask 的根本原因——基类的处理模型是"产品→商品"的一对多,而 TikTok Shop 是"产品×仓库→商品×仓库"的多对多。


Q14:ES 索引重建 400W 数据,你是怎么做到平滑迁移的?

回答思路:说分批策略和风险控制。

参考回答:

TikTok Shop 接入后,ES 文档的 _id 从 goodsId 变成了 summaryId,新增了 platformWarehouseId 字段,旧文档无法增量修复,必须全量重建。

我的方案是利用 Maxwell CDC 的 Bootstrap 功能,按平台分治:

  • eBay 数据量最大(~290W),按 available_quantity_updated_at 时间段分 6 批,每批控制在 ~50W 条
  • Shopify 和 TikTok Shop 数据量小,直接全量 bootstrap

每批执行后观察 Kafka consumer lag 降为 0 再执行下一批。重建期间,CDC 消费者正常工作,新增/修改的数据通过实时 CDC 消息保证不丢失。

风险点是重建期间前端查询可能返回不完整数据,所以提前通知了业务方,选择在业务低峰期执行。


Q15:为什么选择 ClickHouse 而不是直接在 MySQL 上做库存计算?

回答思路:从数据量和查询模式分析。

参考回答:

两个核心原因:

  1. 数据量:亿级库存数据的 GROUP BY 聚合,MySQL 扛不住。ClickHouse 的列式存储天然适合这种"少列多行"的聚合场景,同样的查询在 ClickHouse 上可能快 10-100 倍。
  2. 不影响主库:库存表是核心业务表,每秒都有大量的读写操作(订单扣库存、入库加库存等)。如果在 MySQL 上跑大规模聚合查询,会严重影响主库性能。通过 MaterializedMySQL 同步到 ClickHouse,计算完全在副本上执行,主库零感知。

实际上我们的架构是:MySQL 负责 OLTP(事务性操作),ClickHouse 负责 OLAP(分析性计算),通过 MaterializedMySQL 引擎实现秒级同步,是一个典型的 HTAP 分离架构。


简历提炼版

可售数量自动更新系统(eBay / Shopify / TikTok Shop)

  • 设计并实现基于 MaterializedMySQL + ClickHouse 的增量快照对比方案,覆盖亿级商品(eBay 8000W),通过快照差异计算将每小时处理量从亿级降至万级,支撑常规/换发/组合三种库存类型的复杂计算
  • 采用策略模式 + 产品规则表统一三平台异构库存计算(eBay 按国家、Shopify 5种规则按企业个性化、TikTok Shop 逻辑仓+物理仓混合),支撑产品→商品维度转换与优先级上报
  • 针对 eBay 设计 CompletableFuture + 线程池(50并发)+ 批量降级的多线程上报方案,配合三层分布式锁与白名单灰度机制,三平台零事故逐步接入
  • 主导 TikTok Shop 接入引发的 ES 索引重建(400W 数据),采用 Maxwell CDC Bootstrap 按平台分治、按时间分批重放,平滑完成数据迁移
相关阅读

8 分钟

Creeks 系统线程池与多线程使用分析

整个系统使用了 **3 种多线程模式**,涉及 **4 个独立的线程池配置类**,覆盖 **定时任务、并行查询、异步通知、报告处理** 等场景。 假设服务器 8 核 CPU: Amazon 同步服务选 是明智的——调用者自己跑任务,自动降速

面试#其他9 阅读 · 0 评论

22 分钟

多平台财务核算系统

集团旗下跨境电商业务覆盖 eBay、AliExpress(速卖通)、Wish、Shopee、Walmart、Allegro、美客多、Joom、Joybuy、CDiscount、自建站(Shopify)等 **13+ 电商平台**,每个平台的

面试#其他10 阅读 · 0 评论

评论需要登录。生产环境使用 GitHub 登录,本地可前往 登录页 使用开发登录。

还没有评论,来抢沙发。