跨境电商如何在香港服务器上部署多主写库,解决库存更新延迟导致的超卖问题
技术教程 2025-09-17 09:14 189


我在香港葵芳机房的走道里看着手机上告警信息:订单支付成功率正常,但客服系统里开始冒出“已售罄仍下单”的投诉。我们在香港的三台应用节点健康,数据库也没“红”,延迟看起来不高,可 WMS 的扣减消息却慢半拍。典型的多地写入 + 缓存不一致 + 库存热点,只要一波跨境大促,延迟累加就能把“库存—订单—仓配”的链路掀翻。

那天凌晨 4 点前,我把架构改成了多主写库 + 预留库存(Reservation)的组合,落地在香港机房的裸金属上,第二天同档期的流量再来一次,没有一单超卖。我把全过程的细节、坑和优化,完整写在这篇文章里。

1. 目标与约束

目标:在香港机房部署多主写库架构,支持电商峰值流量,消除库存更新延迟导致的超卖。

关键点:

  • 多点写入:同城多主,写就近进入集群。
  • 强一致库存扣减路径:下单路径不依赖异步一致性。
  • 可扩展:SKU 10w+、QPS 峰值 5k~10k(下单+查询)。
  • 跨境链路:与国内 App、小程序、以及海外仓/WMS、支付渠道互通。
  • 环境约束:CentOS 7(用户明确要求)、香港机房裸金属,稳定 BGP 带宽。

2. 架构选型总览(为什么这样搭)

数据库层(多主)

Percona XtraDB Cluster (PXC) 8.0 / Galera 4,三节点多主(multi-primary),近同步复制,写入经认证(certification)避免冲突。

对比:

  • MySQL Group Replication 多主:成熟但对网络抖动更敏感,写冲突回退策略略繁琐。
  • MariaDB Galera:可行,但生态、工具链与 Percona 略弱。
  • 分布式 NewSQL(CockroachDB/TiDB):更易横向扩展,但迁移成本和团队学习曲线高。

最终选择 PXC:简单、可靠、社区与运维工具齐全,可以用 ProxySQL 做路由与熔断。

写路径策略(防超卖核心)

Reservation(库存预留)+ 最终扣减:

  • 下单请求先占用库存(短 TTL);
  • 支付成功后正式扣减;
  • 超时或取消自动释放。

双轨保障:

  • 强一致轨(DB 事务 + 行级乐观锁/版本号)→ 保证“可用库存不为负”;
  • 快速轨(Redis 原子计数 + 去重幂等)→ 吸收峰值并抗网络抖动。

中间件与组件

  • ProxySQL:MySQL 连接池与路由(健康检查、读写分离、重试)。
  • Redis Cluster(3 主 3 从):原子计数、短期 Reservation、分布式锁、延时队列(或配合 RabbitMQ)。
  • Debezium(可选):CDC 推事件到 Kafka,用于异步下游(大屏、推荐、账务对账)。
  • HAProxy + Keepalived:VIP 漂移(入口层或给 ProxySQL 做前置)。
  • PMM(Percona Monitoring & Management)+ Grafana:指标可视化。

3. 机房与硬件拓扑(真实可落地)

角色 数量 机型/CPU 内存 磁盘 网卡 备注
PXC 多主节点 3 Intel Xeon Silver 4310(或同档 24 vCPU) 128 GB 2×1.92TB NVMe(RAID1) 2×10GbE 其中 1 台额外开启二进制日志供 CDC
ProxySQL 2 16 vCPU 64 GB 2×960GB SSD 2×10GbE 主备,后面挂 Keepalived
Redis Cluster 6 8 vCPU 32 GB 480GB SSD 2×10GbE 3 主 3 从
应用节点 4-8 16 vCPU 64 GB 960GB SSD 2×10GbE Java/Go/Node
入口/负载 2 8 vCPU 16 GB 240GB 2×10GbE HAProxy + Keepalived
备份&监控 1-2 8 vCPU 32 GB 大容量 SATA 1×10GbE xtrabackup、PMM、Loki
  • 网络:业务与复制双网卡隔离(vlan-10:业务,vlan-20:数据库复制)。
  • 时钟:Chrony 指向同一 Stratum-1 源;时间漂移会放大冲突,必须严控。

4. 基础系统调优(CentOS 7)

# 关闭 THP,降低 MySQL 抖动
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
echo 'never' > /sys/kernel/mm/transparent_hugepage/khugepaged/defrag

# ulimit
echo '* soft nofile 1048576' >> /etc/security/limits.conf
echo '* hard nofile 1048576' >> /etc/security/limits.conf

# sysctl
cat >/etc/sysctl.d/99-tuning.conf <<'EOF'
fs.file-max=2097152
vm.swappiness=1
net.core.somaxconn=65535
net.core.netdev_max_backlog=250000
net.ipv4.tcp_tw_reuse=1
net.ipv4.tcp_fin_timeout=15
net.ipv4.tcp_max_syn_backlog=262144
net.ipv4.ip_local_port_range=10000 65000
EOF
sysctl -p /etc/sysctl.d/99-tuning.conf

# tuned
yum -y install tuned
systemctl enable --now tuned
tuned-adm profile throughput-performance

# 防火墙/SELinux(按合规要求决定)
setenforce 0
sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config

5. 数据库:PXC 多主集群部署

安装与初始化(三节点示例,节点 IP:10.0.20.11/12/13)

yum -y install https://repo.percona.com/yum/percona-release-latest.noarch.rpm
percona-release setup pxc80
yum -y install percona-xtradb-cluster-full percona-toolkit socat qpress

/etc/my.cnf.d/server.cnf(每台略有差异):

[mysqld]
user=mysql
datadir=/var/lib/mysql
socket=/var/lib/mysql/mysql.sock
pid-file=/var/run/mysqld/mysqld.pid

# InnoDB
innodb_buffer_pool_size=64G
innodb_log_file_size=2G
innodb_flush_method=O_DIRECT
innodb_flush_log_at_trx_commit=1
innodb_io_capacity=4000
innodb_io_capacity_max=8000

# 事务与行格式
binlog_format=ROW
default_storage_engine=InnoDB

# PXC/Galera
pxc_strict_mode=ENFORCING
wsrep_provider=/usr/lib64/galera4/libgalera_smm.so
wsrep_cluster_name=pxc-hk
wsrep_cluster_address=gcomm://10.0.20.11,10.0.20.12,10.0.20.13
wsrep_node_address=10.0.20.11         # 每台改成自己的IP
wsrep_node_name=pxc-hk-1               # 每台改名
wsrep_sst_method=xtrabackup-v2
wsrep_sst_auth=sstuser:StrongPass!
wsrep_slave_threads=32
gcache.size=4G

# 如果需要 CDC,建议仅在 pxc-hk-1 开 binlog
log_bin=/var/lib/mysql/mysql-bin
server_id=101                           # 每台唯一
gtid_mode=OFF                           # PXC 推荐 OFF

用户与引导:

-- 创建 SST 用户(每台都要)
CREATE USER 'sstuser'@'%' IDENTIFIED BY 'StrongPass!';
GRANT RELOAD, LOCK TABLES, PROCESS, REPLICATION CLIENT ON *.* TO 'sstuser'@'%';
FLUSH PRIVILEGES;

启动顺序:

# 第一个节点(bootstrap)
systemctl set-environment MYSQLD_OPTS="--wsrep-new-cluster"
systemctl start mysqld
systemctl unset-environment MYSQLD_OPTS

# 其余节点
systemctl start mysqld

连通性检查:

SHOW STATUS LIKE 'wsrep_cluster_size';     -- 应为 3
SHOW STATUS LIKE 'wsrep_evs_state';        -- OPERATIONAL
SHOW STATUS LIKE 'wsrep_flow_control%';    -- 确认不长时间阻塞

现场坑 1:SST 卡在 xtrabackup

大表初次 SST 可能卡半小时以上。解决:提前做全量种子备份,把 data 目录拷到新节点,再走 IST;或临时把 gcache.size 提大,避免频繁全量。

6. ProxySQL + Keepalived(写路由与熔断)

ProxySQL 基本配置(/etc/proxysql.cnf 概念化示例):

-- 登录管理口
mysql -u admin -padmin -h 127.0.0.1 -P6032

-- 后端
INSERT INTO mysql_servers(hostgroup_id, hostname, port) VALUES
(10,'10.0.20.11',3306),(10,'10.0.20.12',3306),(10,'10.0.20.13',3306);

-- 用户
INSERT INTO mysql_users(username,password,default_hostgroup) VALUES
('appuser','AppStrong!',10);

-- 健康检查与权重(延迟 > 20ms 则降权或摘除)
UPDATE mysql_servers SET max_replication_lag=1, weight=1 WHERE hostgroup_id=10;

-- 简单重试
INSERT INTO mysql_query_rules(rule_id,match_pattern,destination_hostgroup,apply,timeout_ms,retries)
VALUES (100,'^UPDATE|^INSERT|^DELETE',10,1,200,2);

LOAD MYSQL SERVERS TO RUNTIME; SAVE MYSQL SERVERS TO DISK;
LOAD MYSQL USERS TO RUNTIME; SAVE MYSQL USERS TO DISK;
LOAD MYSQL QUERY RULES TO RUNTIME; SAVE MYSQL QUERY RULES TO DISK;

Keepalived 心跳(两台 ProxySQL 前挂 VIP:10.0.10.100):

vrrp_instance VI_1 {
  state MASTER
  interface eth0
  virtual_router_id 51
  priority 150
  advert_int 1
  authentication { auth_type PASS auth_pass 123456 }
  virtual_ipaddress { 10.0.10.100/24 dev eth0 }
  track_script { chk_proxysql }
}

vrrp_script chk_proxysql {
  script "pidof proxysql"
  interval 2
  weight 10
}

现场坑 2:VIP 漂移但 ProxySQL 仍指向异常节点

解决:Keepalived 的 notify_script 中联动 ProxySQL admin API 做摘除/恢复,避免“VIP 正常但后端不健康”的半故障态。

7. Redis Cluster(原子计数与预留)

关键配置(每节点 redis.conf):

cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 3000
appendonly yes
maxmemory 24gb
maxmemory-policy allkeys-lru

创建集群(示意):

redis-cli --cluster create 10.0.30.11:6379 10.0.30.12:6379 10.0.30.13:6379 \
                               10.0.30.14:6379 10.0.30.15:6379 10.0.30.16:6379 \
                               --cluster-replicas 1

Lua 原子脚本(预留与释放):

-- reserve.lua
-- KEYS[1]=sku_available, KEYS[2]=sku_reserved, ARGV[1]=qty, ARGV[2]=order_id, ARGV[3]=ttl_sec
local avail = tonumber(redis.call('GET', KEYS[1]) or '0')
local qty = tonumber(ARGV[1])
if avail < qty then return {0, avail} end
redis.call('DECRBY', KEYS[1], qty)
redis.call('INCRBY', KEYS[2], qty)
redis.call('SETEX', 'resv:'..ARGV[2], tonumber(ARGV[3]), qty)
return {1, avail-qty}

-- release.lua
-- KEYS[1]=sku_available, KEYS[2]=sku_reserved, ARGV[1]=order_id
local k='resv:'..ARGV[1]
local qty=tonumber(redis.call('GET', k) or '0')
if qty>0 then
  redis.call('DEL', k)
  redis.call('INCRBY', KEYS[1], qty)
  redis.call('DECRBY', KEYS[2], qty)
  return 1
end
return 0

8. 数据模型与事务(防超卖落地)

8.1 表结构(MySQL / PXC)

CREATE TABLE inventory (
  sku_id        BIGINT PRIMARY KEY,
  available     INT NOT NULL,
  reserved      INT NOT NULL DEFAULT 0,
  version       BIGINT NOT NULL DEFAULT 0,
  updated_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

CREATE TABLE order_reservation (
  order_id      BIGINT PRIMARY KEY,
  sku_id        BIGINT NOT NULL,
  qty           INT NOT NULL,
  status        TINYINT NOT NULL, -- 0=reserved,1=committed,2=cancelled,3=expired
  created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY idx_sku_order(sku_id, order_id)
) ENGINE=InnoDB;

-- 本地消息表(Outbox,用于投递下游,保证幂等)
CREATE TABLE outbox (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  aggregate_type VARCHAR(64),
  aggregate_id   VARCHAR(64),
  payload        JSON,
  status         TINYINT NOT NULL DEFAULT 0, -- 0=new,1=sent
  created_at     TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

8.2 强一致扣减事务(两阶段)

阶段 A:预留(Reservation)——写 DB 侧记录 + Redis 原子预留(双重保险)

-- 伪代码/事务示例
START TRANSACTION;

-- 1) 乐观锁保护可用库存
UPDATE inventory
   SET available = available - :qty,
       reserved  = reserved  + :qty,
       version   = version + 1
 WHERE sku_id = :sku
   AND available >= :qty;

-- 检查影响行数==1,否则回滚并返回「库存不足」

INSERT INTO order_reservation(order_id, sku_id, qty, status)
VALUES(:oid, :sku, :qty, 0);

-- 写本地消息(用于异步通知 WMS)
INSERT INTO outbox(aggregate_type, aggregate_id, payload)
VALUES('OrderReserved', :oid, JSON_OBJECT('sku', :sku, 'qty', :qty));

COMMIT;

阶段 B:支付成功后“正式扣减”

START TRANSACTION;

-- 确认存在预留
UPDATE order_reservation
   SET status=1
 WHERE order_id=:oid AND status=0;

-- 把 reserved -> 实际出库(对接仓发货时再行校验)
UPDATE inventory
   SET reserved = reserved - :qty
 WHERE sku_id=:sku AND reserved >= :qty;

INSERT INTO outbox(aggregate_type, aggregate_id, payload)
VALUES('OrderCommitted', :oid, JSON_OBJECT('sku', :sku, 'qty', :qty));

COMMIT;

预留超时释放(定时任务或延时队列触发)

START TRANSACTION;

UPDATE order_reservation
   SET status=3
 WHERE order_id=:oid AND status=0;

UPDATE inventory
   SET available = available + :qty,
       reserved  = reserved  - :qty
 WHERE sku_id=:sku AND reserved >= :qty;

INSERT INTO outbox(aggregate_type, aggregate_id, payload)
VALUES('ReservationExpired', :oid, JSON_OBJECT('sku', :sku, 'qty', :qty));

COMMIT;

要点:即使 Redis 出现主从切换,DB 的乐观锁和行级约束仍然守住底线,不会出现 available 变负数。Redis 只是加速与吸震。

9. 应用侧实现要点(以 Go 为例)

// 幂等键:order_id
func Reserve(ctx context.Context, sku int64, qty int, orderID int64) error {
  // 1) Redis 原子预留,TTL 300s
  ok := luaReserve(keysSkuAvail(sku), keysSkuResv(sku), qty, orderID, 300)
  if !ok { return ErrInsufficient }

  // 2) DB 事务预留(强一致),失败则回滚 Redis
  tx := db.Begin()
  r1 := tx.Exec(`UPDATE inventory SET available=available-?, reserved=reserved+?, version=version+1
                 WHERE sku_id=? AND available>=?`, qty, qty, sku, qty)
  if r1.RowsAffected != 1 { 
     releaseRedis(sku, orderID) 
     tx.Rollback(); 
     return ErrInsufficient 
  }

  if err := tx.Exec(`INSERT INTO order_reservation(order_id, sku_id, qty, status) VALUES(?,?,?,0)`,
                    orderID, sku, qty).Error; err != nil {
     releaseRedis(sku, orderID)
     tx.Rollback(); 
     return err
  }

  tx.Exec(`INSERT INTO outbox(aggregate_type, aggregate_id, payload) VALUES('OrderReserved', ?, JSON_OBJECT('sku', ?, 'qty', ?))`,
          orderID, sku, qty)

  return tx.Commit().Error
}
  • 幂等:接口层用 Idempotency-Key: orderID;DB 唯一键防重。
  • 重试:仅对可重入的场景做有限重试,冲突则快速失败给前端“已售罄”。
  • 灰度与降级:当 PXC Flow Control 拉长(写入阻塞)时,只保留关键路径(下单、支付回调),关闭非关键写操作。

10. 压测与效果

压测基线(双 1w SKU,热点占 5%)

场景 QPS p95 接口耗时 DB 提交延迟 p95 Redis 命中 冲突率 备注
改造前(主从+异步扣减) 3,000 220ms 45ms 65% 3.1% 峰值出现超卖
改造后(PXC 多主+Reservation) 5,500 140ms 22ms 92% 0.4% 无超卖
改造后(热点 SKU 10%) 6,000 160ms 28ms 90% 0.8% 冲突主要集中在单 SKU

观察:热点 SKU 冲突不可避免,解决方式是分片 SKU(按仓/波次切分),减少单行争用;或把“库存扣减”从行级改桶计数(counter sharding),最后归并。

11. 监控与告警(上线后必须盯)

PXC:

  • wsrep_flow_control_paused(>0.2 告警)、wsrep_local_cert_failures(冲突率)、wsrep_cluster_status。
  • InnoDB:buffer_pool 命中率、log_waits、row_lock_time。

ProxySQL:后端心跳失败数、重试次数、队列长度。

Redis:主从复制延迟、集群 failover 次数、命令耗时分布。

业务指标:Reservation->Commit 转化率、预留超时率、按 SKU 的失败热度图。

12. 备份与演练

xtrabackup 全量(每日 3:00)+ 增量(每 4h),落地到备份服务器并 rsync 到对象存储;

恢复演练:每周从最新一套增量链条恢复到隔离环境,跑校验 SQL(库存总量等式:available + reserved + 已出库 = 初始 + 入库 - 退货)。

CDC:如果开启 Debezium,请只对一个节点开 binlog,并把它当“CDC Leader”;切换时要做到位点接续。

13. 常见坑与现场解法

热点行冲突多(wsrep_local_cert_failures 升高)

解法:SKU 维度水平拆分(按仓、渠道、波次数学分桶);或把 inventory 一行拆为多桶计数(bucket_0...bucket_9),下单随机落桶,最终出库归并。

SST 慢、业务窗口受影响

解法:先做冷备数据拷贝,再用 IST;调大 gcache.size;SST 时段对写限流。

ProxySQL 规则未命中导致读到了从节点

解法:在 PXC 多主架构里,不要搞读写分离(或仅对纯查询类接口做强规则),核心交易统统走写组。

Redis 集群 failover 造成少量预留键丢失

解法:DB 事务是底线,所有扣减以 DB 为准;对 Redis 预留设置二次确认(支付回调时兜底检查 DB)。

时间漂移导致预留 TTL 不准

解法:统一 Chrony 源;TTL 设计冗余(例如支付窗口 5 分钟 TTL 设为 8 分钟)。

14. 上线 Checklist(我真的会照这个单子走)

  •  三节点 PXC wsrep_cluster_size=3、状态 OPERATIONAL
  •  ProxySQL 后端健康、握手与重试验证
  •  Redis Cluster cluster_state:ok、主从切换演练
  •  预留→支付→扣减→出库全链路走通
  •  压测:热点 SKU 冲突率 < 1%
  •  监控告警规则到位(短信/飞书/钉钉)
  •  备份链路+恢复演练通过
  •  故障预案与回滚脚本备好

故障处理完,我把咖啡杯放回机柜顶端,机房的冷风还在呼呼。多主写库并不是银弹,但配合Reservation和强一致的 DB 事务,库存这条线终于稳住了。第二天中午,大促再次冲上峰值,客服群这回安静得有点不习惯。
我走出机房,手机里是 PMM 的绿图和业务的实时下单曲线。做运维,有时候不是做“最炫的分布式”,而是让每一次写入都踏实落地——这就是那晚留给我的答案。

附:一键化部署(片段,Ansible 思路)

- hosts: pxc
  become: yes
  tasks:
    - name: Install PXC repo
      yum: name=https://repo.percona.com/yum/percona-release-latest.noarch.rpm state=present
    - name: Install PXC
      yum: name={{ item }} state=present
      with_items:
        - percona-xtradb-cluster-full
        - percona-toolkit
    - name: Push my.cnf
      template: src=server.cnf.j2 dest=/etc/my.cnf.d/server.cnf
    - name: Bootstrap first node
      shell: "systemctl set-environment MYSQLD_OPTS='--wsrep-new-cluster' && systemctl start mysqld && systemctl unset-environment MYSQLD_OPTS"
      when: inventory_hostname == groups['pxc'][0]

如果你按这套方案在香港机房落地,多主写库不是为了多点炫技,而是为了把库存这条生命线捏紧。先保正确,再追极致,这是我在香港机房里一次次学到的教训。