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

凌晨 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 路由到临时专属分片,待流量回落再迁回;
- 提前为爆款建立“库存沙箱”,单独容量与阈值,避免拖累全局。