上一篇 下一篇 分享链接 返回 返回顶部

跨境电商网站如何在香港服务器的 Ubuntu 环境中配置数据库分片,解决高并发下的库存不一致问题

发布人:Minchunlin 发布时间:2025-09-14 08:55 阅读量:598


凌晨 1:40,香港将军澳数据中心三楼的一台 2U 服务器的机箱开着,前面板 8 块 U.2 NVMe 灯闪成一条微弱的光带。今晚我们要把电商库存服务从单库架构迁到分片架构,目标很朴素——“不再超卖”。

七天前的一个东南亚大促,我们在 2.2 万 QPS 的峰值下出现了 0.037% 的超卖率。听起来不高,但对单个爆款 SKU 来说,一次 2000 单的波峰就可能多扣几十件。用户投诉、仓库对账、跨境关务调整……每一个后果都很昂贵。

这次改造,我选了 MySQL 8.0 + Apache ShardingSphere-Proxy 的分片中间层方案,运行在 Ubuntu 22.04 LTS 的香港物理服务器上;读写分离走 Proxy 的内置规则,分布式 ID 选了 Snowflake,并在库存扣减上采用“原子更新 + 预占 + 事务消息”的组合拳,避免跨分片分布式事务的长尾与复杂性。

一、目标与约束

目标

  • 把库存不一致率(误扣/超卖/负库存)控制在 ≤ 0.001%。
  • 峰值 50k QPS 的下单流量下,p95 < 25 ms(库存写路径,含 Proxy)。
  • 架构支持从 8 分片平滑扩容到 32 分片,无需停机重分片。

约束

  • 必须部署在 香港本地机房,靠近清关与支付通道,降低跨境延迟。
  • 使用 Ubuntu 22.04 LTS,内核与包管理统一,便于后续扩容、回滚。
  • 研发侧只改最小化代码(主要是引入分片键与幂等令牌),其余通过中间件与数据库层解决。

二、硬件与网络拓扑(现场配置单)

角色 型号/参数 数量 关键点
Shard 主库(8 个分片起步) 2U 机架式;AMD EPYC 9454(48C);256GB DDR5;8×3.84TB U.2 NVMe(RAID10 by mdadm);双电;双 25GbE 8 单机本地 NVMe,顺序写 >3GB/s,随机 4k > 900k IOPS;NUMA 绑定 mysql 实例亲和
副本节点 同主库但内存 192GB 8 单主双从起步:1 个热备读,1 个延迟只读(延迟 5s)
Proxy 层 1U;Intel Xeon Gold 6430(32C);64GB;系统盘 NVMe;双 25GbE 4 部署 ShardingSphere-Proxy,四节点 Anycast VIP
接入层(Nginx + 应用) 云上 + 机房混部 若干 七层限流 + 灰度开关;gRPC 与 HTTP/2
交换机 25GbE TOR ×2(MLAG) 2 机内冗余,跨机架 ECMP

网络

  • 业务网:25GbE,MTU 9000,TOR 间 ECMP;跨机架平均 RTT 80–120 µs。
  • 存储/复制网分离 VLAN,复制走 BBR 拥塞控制。
  • 时钟:Chrony 同步到本地 Stratum-1(GPS),误差 < 1 ms,保证 Snowflake 单调性。

三、系统与内核调优(Ubuntu 22.04 LTS)

基础系统

# 固定时区为香港
sudo timedatectl set-timezone Asia/Hong_Kong

# 更新与基础包
sudo apt update && sudo apt -y upgrade
sudo apt -y install chrony net-tools numactl tuned htop iotop sysstat jq unzip

# Netdev 巨帧(视交换机与链路一致)
for i in $(ls /sys/class/net | grep -E 'ens|eth'); do sudo ip link set $i mtu 9000 || true; done

# 文件句柄与进程限制
cat <<'EOF' | sudo tee -a /etc/security/limits.conf
* soft nofile 1048576
* hard nofile 1048576
mysql soft nofile 1048576
mysql hard nofile 1048576
EOF

sysctl 网络与存储

cat <<'EOF' | sudo tee /etc/sysctl.d/99-tuning.conf
vm.swappiness=1
vm.dirty_ratio=5
vm.dirty_background_ratio=2
vm.max_map_count=262144
fs.file-max=2097152

net.core.rmem_max=134217728
net.core.wmem_max=134217728
net.core.netdev_max_backlog=250000
net.ipv4.tcp_rmem=4096 87380 134217728
net.ipv4.tcp_wmem=4096 65536 134217728
net.ipv4.tcp_congestion_control=bbr
net.ipv4.tcp_mtu_probing=1
net.core.somaxconn=65535
net.ipv4.ip_local_port_range=10000 65535
EOF
sudo sysctl --system

NVMe 与 RAID10(mdadm)

# 8 块 NVMe 做 4 组镜像再条带:((RAID1+RAID0) aka RAID10)
sudo apt -y install mdadm
sudo mdadm --create /dev/md0 --level=10 --raid-devices=8 /dev/nvme{0..7}n1
sudo mkfs.xfs -f /dev/md0
sudo mkdir -p /data && echo '/dev/md0 /data xfs noatime,nodiratime 0 0' | sudo tee -a /etc/fstab
sudo mount -a

四、MySQL 8.0 安装与参数(主从 + GTID)

安装

sudo apt -y install mysql-server mysql-client
sudo systemctl enable --now mysql
mysql --version  # 确认 8.0+

my.cnf(以 256GB 内存主库为例)

# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
user=mysql
datadir=/data/mysql
tmpdir=/data/mysqltmp
socket=/var/run/mysqld/mysqld.sock
pid-file=/run/mysqld/mysqld.pid
bind-address=0.0.0.0

# InnoDB
innodb_buffer_pool_size=160G
innodb_buffer_pool_instances=16
innodb_log_file_size=8G
innodb_log_files_in_group=2
innodb_flush_method=O_DIRECT
innodb_flush_log_at_trx_commit=1
innodb_io_capacity=40000
innodb_io_capacity_max=80000
innodb_undo_log_truncate=ON

# 连接与线程
max_connections=4000
thread_handling=one-thread-per-connection

# 二进制日志与复制
server_id=10${SHARD_ID}01  # 每分片唯一
log_bin=/data/mysql/binlog/mysql-bin
binlog_format=ROW
gtid_mode=ON
enforce_gtid_consistency=ON
master_info_repository=TABLE
relay_log_info_repository=TABLE
log_slave_updates=ON
binlog_expire_logs_seconds=604800  # 7 天

# 其他
skip_name_resolve=ON
sql_require_primary_key=ON
explicit_defaults_for_timestamp=ON

注:innodb_log_file_size 调大到 8G 后,强制重启一次以创建新 redo;server_id 结合分片 ID 保证唯一。

复制拓扑

每个分片:1 主 + 2 从(其中一个延迟只读 5s,做容灾回滚窗口)。

复制用 异步 + Semi-sync 插件(重要写路径不等待半同步确认,保证尾延迟)。

-- 主库创建复制用户
CREATE USER 'repl'@'10.%' IDENTIFIED BY 'StrongPass!';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'10.%';
FLUSH PRIVILEGES;

-- 从库上
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='10.0.1.10', SOURCE_USER='repl', SOURCE_PASSWORD='StrongPass!',
  SOURCE_AUTO_POSITION=1;  -- GTID
START REPLICA;

五、ShardingSphere-Proxy 部署与核心配置

安装

ShardingSphere-Proxy 需要 JDK 8+,我选 OpenJDK 17。

sudo apt -y install openjdk-17-jre-headless
wget https://archive.apache.org/dist/shardingsphere/5.4.2/apache-shardingsphere-5.4.2-shardingsphere-proxy-bin.tar.gz
sudo mkdir -p /opt/shardingsphere && sudo tar -xzf apache-*.tar.gz -C /opt/shardingsphere --strip-components=1
useradd -r -s /bin/false ssproxy && chown -R ssproxy:ssproxy /opt/shardingsphere

后端数据源(8 分片 × 主从)

/opt/shardingsphere/conf/server.yaml

mode:
  type: Standalone
  repository:
    type: JDBC
props:
  proxy-frontend-database-protocol-type: MySQL
  sql-show: false
  check-table-metadata-enabled: true

/opt/shardingsphere/conf/config-sharding.yaml

databaseName: ecommerce

dataSources:
  ds_0_primary: { url: jdbc:mysql://10.0.1.10:3306/ecom, username: app, password: *** }
  ds_0_replica: { url: jdbc:mysql://10.0.1.11:3306/ecom, username: app, password: *** }
  ds_1_primary: { url: jdbc:mysql://10.0.2.10:3306/ecom, username: app, password: *** }
  ds_1_replica: { url: jdbc:mysql://10.0.2.11:3306/ecom, username: app, password: *** }
  # ... 直到 ds_7

rules:
- !READWRITE_SPLITTING
  dataSources:
    rw_0:
      writeDataSourceName: ds_0_primary
      readDataSourceNames: [ds_0_replica]
    rw_1:
      writeDataSourceName: ds_1_primary
      readDataSourceNames: [ds_1_replica]
    # ... rw_7
  loadBalancers:
    round_robin:
      type: ROUND_ROBIN

- !SHARDING
  tables:
    inv_stock:
      actualDataNodes: rw_${0..7}.inv_stock_${0..15}
      tableStrategy:
        standard:
          shardingColumn: sku_id
          shardingAlgorithmName: t-hash-mod-16
      databaseStrategy:
        standard:
          shardingColumn: sku_id
          shardingAlgorithmName: db-hash-mod-8
      keyGenerateStrategy:
        column: id
        keyGeneratorName: snowflake

  shardingAlgorithms:
    db-hash-mod-8:
      type: HASH_MOD
      props:
        sharding-count: 8
    t-hash-mod-16:
      type: HASH_MOD
      props:
        sharding-count: 16

  keyGenerators:
    snowflake:
      type: SNOWFLAKE
      props:
        worker-id: ${WORKER_ID}

- !BROADCAST
  tables: [dict_currency, dict_region]

解释:我们对 inv_stock 做 库分片 8 × 表分片 16;分片键统一使用 sku_id,避免路由不确定导致广播;公共字典表广播到所有库。

启动与守护

sudo -u ssproxy nohup /opt/shardingsphere/bin/start.sh > /var/log/ss-proxy.log 2>&1 &
# 监听 3307(默认),在负载均衡器做 4 节点 Anycast VIP

六、表结构与库存一致性模式

表结构(简化)

CREATE TABLE inv_stock (
  id bigint PRIMARY KEY,
  sku_id bigint NOT NULL,
  warehouse_id int NOT NULL,
  stock int NOT NULL,
  reserved int NOT NULL DEFAULT 0,
  version int NOT NULL DEFAULT 0,
  updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_sku_wh (sku_id, warehouse_id)
) ENGINE=InnoDB;

CREATE TABLE inv_reservation (
  id bigint PRIMARY KEY,
  order_no varchar(64) NOT NULL,
  sku_id bigint NOT NULL,
  warehouse_id int NOT NULL,
  qty int NOT NULL,
  expire_at timestamp NOT NULL,
  status tinyint NOT NULL DEFAULT 0, -- 0=RESERVED,1=COMMIT,2=CANCEL
  UNIQUE KEY uk_order_sku (order_no, sku_id)
) ENGINE=InnoDB;

三步走:原子扣减 + 预占 + 事务消息

库存预占(Reservation) – 避免长事务锁住热行。

-- 单条 SKU 预占(乐观版本)
UPDATE inv_stock
   SET reserved = reserved + :need,
       version = version + 1
 WHERE sku_id = :sku AND warehouse_id = :wh
   AND stock - reserved >= :need
   AND version = :ver;  -- 读到的版本号

-- 受影响行==1 表示预占成功;失败则重试(带退避)或切仓

支付回调后提交或取消:

-- 提交:从 reserved 扣到 stock
UPDATE inv_stock
   SET stock = stock - :need,
       reserved = reserved - :need,
       version = version + 1
 WHERE sku_id = :sku AND warehouse_id = :wh
   AND reserved >= :need;

-- 取消:释放预占
UPDATE inv_stock
   SET reserved = reserved - :need,
       version = version + 1
 WHERE sku_id = :sku AND warehouse_id = :wh
   AND reserved >= :need;

事务消息(Outbox) – 与订单库同一分片内提交,异步通知下游(缓存、搜索、WMS):

-- 订单库(与库存同分片,按用户或订单号分片)
INSERT INTO outbox(event_type, payload, created_at, status)
VALUES ('INV_RESERVED', JSON_OBJECT('order', :order_no, 'sku', :sku, 'qty', :need), NOW(), 0);
-- 本地事务提交后,消息轮询投递到 Kafka / Pulsar

关键点:不做跨分片分布式事务。将强一致边界收缩到单分片,跨分片依赖最终一致(消息与幂等)。

幂等与防重

订单号 + SKU 作为幂等键(inv_reservation.uk_order_sku)。

应用侧所有扣减/提交接口强制携带 Idempotency-Key(UUID),在 gateway 做 1 分钟窗口去重。

七、应用层改造最小集

分片键穿透

所有涉及库存的 SQL 必须包含 sku_id,否则 Proxy 会广播路由,抖动极大。

JPA/ORM 层增加路由拦截器:无 sku_id 则拒绝发起查询。

读写一致性策略

下单链路全部走 强一致读(Proxy 事务内强制路由主库)。

非关键查询(列表、详情页)允许读从,且引入 read-your-writes 窗口:写后 200ms 内同会话直达主库。

分布式 ID

Snowflake:datacenterId 取机房区,workerId 取 Proxy 节点 ID;Chrony 失步报警直接摘除节点。

八、分片规划与扩容策略

阶段 分片数(库×表) 预计 SKU 峰值 QPS 说明
T0 8×16 30 万 20k 初始上线
T1 16×16 80 万 35k 水平扩容,加库,不动表
T2 32×16 150 万 50k 再扩库;预留 64×16 的 ID 空间

预分片路由:HASH_MOD,保留高位空间做 RangeRehash,扩容时只迁移部分桶;迁移通过 gh-ost + 双写灰度 完成。

九、压测与上线灰度

压测脚本(wrk + Lua)

-- reserve.lua:模拟预占
request = function()
  local sku = math.random(100000, 999999)
  local wh = 1
  local body = string.format('{"sku":%d,"wh":%d,"qty":1}', sku, wh)
  return wrk.format("POST", "/api/inv/reserve", { ["Content-Type"] = "application/json" }, body)
end

 关键指标对比

场景 QPS p50(ms) p95(ms) 错误率 备注
改造前:单库 8,000 9.2 47.8 0.21% 行锁冲突 + 主从延迟读
改造后:8×16 分片 20,000 5.1 18.6 0.02% 预占 + 单分片强一致
大促开闸:写放大 36,000 6.3 22.7 0.03% 事务消息异步下沉

超卖率从 0.037% → 0.0006%,对账差额在统计误差范围内。

十、线上遇到的坑与当天的解决过程

坑一:SQL 被广播,Proxy CPU 飙升

症状:上线 20 分钟后,Proxy 3 号节点 CPU 95%+,延迟飙高;日志看到 Broadcast SQL。

定位:新接入的一条库存统计 SQL 没传 sku_id(只按仓查询)。

处理:

临时:ShardingSphere 开启 sql-show=true,并基于 SQL 防火墙拒绝无分片键的语句;

永久:应用侧拦截器 + 联合索引(warehouse_id, sku_id),所有统计改走离线任务。

坑二:行锁热点导致长尾

症状:特定爆款 SKU 的 inv_stock 单行写热点,p99 抖动。

处理:把 warehouse_id 纳入分片键(sku_id + warehouse_id 的 HASH_MOD),并将同 SKU 按仓分散到不同表分片,热点明显降低。

坑三:时钟回拨导致 ID 冲突熔断

症状:一台 Proxy 因 NTP 源抖动产生小幅回拨,Snowflake 监控报警。

处理:Chrony 改为 makestep 1.0 3,且启用单播本地 Stratum-1;回拨即熔断摘除节点,手动恢复。

坑四:从库延迟导致读到旧库存

症状:详情页偶发显示“库存 0”,但 1 秒后刷新恢复。

处理:关键接口强制主读;非关键接口采用 read-your-writes 窗口 + proxy_hint /*+ PROXY_DS=primary */;同时对从库延迟 > 800ms 的实例自动踢出。

坑五:端口耗尽与 TIME_WAIT 泛滥

症状:压测峰值出现 cannot assign requested address。

处理:

net.ipv4.ip_local_port_range=10000 65535;

net.ipv4.tcp_tw_reuse=1(仅客户端侧),并复用连接池;

Proxy 与应用保持长连接,调大 max_open_connections。

十一、缓存与搜索的一致性(边界处理)

缓存采用 Cache-Aside + TTL 3–5s,只缓存非关键读。关键路径一律直读 DB。

写入路径:DB 成功后 Outbox → Kafka,由消费端更新 Redis 与 ES。所有缓存写入带 幂等键。

大促前启用 热点 Key 预热,避免雪崩。

十二、可观测性与告警基线

指标 阈值 告警策略
Proxy 路由广播比率 < 0.1% 连续 5 分钟 > 阈值,告警到 oncall
从库复制延迟 < 800 ms 超阈摘除读池
单 SKU 行锁等待 p95 < 5 ms 超阈报警 + 自动切仓
超卖率 < 0.001% 连续 10 分钟超阈,关闭促销入口

日志采集:Proxy 与 MySQL Binlog 进入 Loki/S3;AWR 级别的性能剖析定时保留 7 天。

十三、灰度与回滚策略

灰度顺序:1% 流量 → 10% → 25% → 50% → 100%,每级至少观察 30 分钟。

双写保护:灰度期订单与库存写入双路(新分片与旧单库),对比校验差异。

回滚:保留旧单库实时复制链路,切换 DNS 即回旧架构;出站消息通过版本号过滤重复。

十四、面向新手的“最小可行清单”(拎包即用)

  • 准备 8 台主库 + 8 台从库 + 4 台 Proxy,网络 25GbE,Ubuntu 22.04。
  • 按本文 sysctl、limits、RAID10、MySQL 配置部署。
  • 部署 ShardingSphere-Proxy,按 sku_id 作为分片键,8×16 起步。
  • 应用侧引入分片键穿透、幂等键、强一致读策略。
  • 采用“预占→提交/取消→事务消息”的库存一致性;严禁跨分片分布式事务。
  • 压测验证(QPS、p95、超卖率),再灰度上线。

十五、FAQ(现场最常被问到)

Q1:为什么不用分布式事务?A:库存写入极其频繁,2PC 带来的锁跨度与协调延迟会放大长尾;我们把强一致边界缩小到单分片,通过路由保证“相关数据同分片”,剩余跨分片交给最终一致,辅以幂等与补偿。

Q2:不使用 Redis 扣减吗?A:Redis Lua 的确快,但一致性边界难以证明(尤其与 DB 双写);我们将 Redis 退到“读缓存”,写由 DB 原子更新承担,确保资金与库存口径一致。

Q3:如何扩到 32 分片不迁库?A:预留 HASH 桶位,扩容时把哈希区间映射到新增库,迁移只覆盖子集;配合 gh-ost 与双写,做到无感切换。

十六、结尾:凌晨 4:20 的回廊与一条“0.0006%”

凌晨 4:20,我从冷通道出来,走廊里有股轻微的塑胶味,空调的低频嗡嗡声像一条稳住的心跳。Grafana 上那条“超卖率”的曲线扎扎实实地贴在 0.0006% 附近,配色难看却让人安心。手机里,仓库群里一句“库存对上了”,比任何 KPI 都来得实在。

这套方案并不玄学:把强一致留给单分片,把复杂留给异步与幂等。当你也在香港机房的冷风里加班时,希望这篇长文能让你少挨几刀风口,少踩几个凌晨版本的坑。

附录 A|Proxy SQL Hint 示例

/*+ PROXY_HINT(PRIMARY_ONLY=true) */ SELECT * FROM inv_stock WHERE sku_id=? AND warehouse_id=?;

附录 B|系统化巡检脚本片段

#!/usr/bin/env bash
ok() { printf "[OK] %s\n" "$1"; }
warn(){ printf "[WARN] %s\n" "$1"; }

# NUMA 亲和
numactl --hardware | grep 'available' | awk '{print $2}'

# Chrony 状态
chronyc tracking; chronyc sources

# MySQL 延迟
mysql -e "SHOW REPLICA STATUS\G" | egrep 'Seconds_Behind_Master|Replica_IO_Running|Replica_SQL_Running'

# Proxy 存活
ss -lntp | grep 3307 || warn "Proxy down" && ok "Proxy up"

附录 C|应急预案:热 SKU 分散

  • 临时把 warehouse_id 加入分片键权重(Weighted Hash);
  • 将热 SKU 路由到临时专属分片,待流量回落再迁回;
  • 提前为爆款建立“库存沙箱”,单独容量与阈值,避免拖累全局。
目录结构
全文