
我在香港葵芳机房的走道里看着手机上告警信息:订单支付成功率正常,但客服系统里开始冒出“已售罄仍下单”的投诉。我们在香港的三台应用节点健康,数据库也没“红”,延迟看起来不高,可 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]
如果你按这套方案在香港机房落地,多主写库不是为了多点炫技,而是为了把库存这条生命线捏紧。先保正确,再追极致,这是我在香港机房里一次次学到的教训。