Centos 7的香港服务器,如何在黑五大促中用 Redis Cluster 兜住购物车高并发「丢单」

去年黑五,我值守我们香港机房的电商集群。夜里 23:10,购物车和下单链路陡增 30 倍并发,订单追踪面板忽然出现「下单成功率间歇性掉洞」——不是超卖,而是丢单:明明用户点了结算,订单中心没收到;秒级回查日志,应用说写了 Redis,订单服务却没有消费到。那一刻,机房的空调出风口有点冷,我的手心却是热的。
这篇就是我当晚把 Redis Cluster 拉起来、边跑边调、把丢单率从 0.8% 拉回 0.00x% 的全过程(含部署手册 + 调参 + 踩坑复盘)。写给同样在一线扛流量的你。
1.现场环境与目标
1.1 拓扑与硬件(香港机房)
| 角色 | 数量 | 机型/CPU | 内存 | 磁盘 | 网卡 | OS |
|---|---|---|---|---|---|---|
| Redis 节点 | 6 | 2×Xeon Silver 4214R | 128 GB ECC | 2×1.92 TB NVMe(RAID1) | 2×10 GbE | CentOS 7.9 |
| 应用(Cart/Order) | 8 | 1×Xeon Silver 4210 | 64 GB | 480 GB SSD | 10 GbE | CentOS 7.9 |
| 网关/接入层 | 2 | 相同 | 64 GB | SSD | 10 GbE ×2 | CentOS 7.9 |
延迟目标(双 10G 机房内):从 App 打进到 Redis 99p < 2.5 ms;下单端到端 99p < 120 ms;丢单率(下单动作未被订单系统消费)= 0。
1.2 业务约束与痛点
购物车是高写入、短生命周期数据;黑五峰值写放大明显。
之前用单机 Redis + Sentinel,AOF 重写 + 磁盘抖动时出现毛刺,结合客户端重试,造成写入成功回执但消息未被可靠投递的窗口。
需要水平扩展与跨节点容灾,且键空间需要可控路由以支撑 Lua 原子语义。
2. 架构方案:Redis Cluster 在链路里的定位
2.1 分片与副本
3 主 3 从(共 6 节点),16384 槽平均分配,副本跨物理机。
Port 规划:7001–7006 为服务端口;集群总线端口 = 服务端口 + 10000(如 17001–17006),防火墙必须放行内网互通。
2.2 键模型与槽位控制(避免跨槽)
- 购物车键:cart:{uid}(Hash)
- 幂等令牌:order:idem:{uid}:{token}(String,TTL 10 min)
- 预扣库存:stock:{sku}(String/Hash)
- 事件流:stream:order:{uid}(Redis Streams,用相同 key tag {uid} 约束到同槽)
说明:{…} 标签确保 Incr/HIncr/Lua 操作的所有 key 均落在同一槽,从而在 Redis Cluster 内保持事务/Lua 的原子性。
2.3 可靠投递与防丢单
写路径:
1)用户「加入购物车」→ 原子 Lua 写入 cart:{uid}(Hash)并 XADD stream:order:{uid} 生成事件。
2)下单前生成 token,SET NX order:idem:{uid}:{token} TTL,通过 Lua 校验并扣减预扣库存。
读/消费路径:订单服务用 Streams Consumer Group 消费 stream:order:{uid},处理成功后 XACK;失败进入 XPENDING 由重试器认领。
幂等:订单写库前校验 order:idem:*,保证用户重复点击只生一单。
3. 部署手册(CentOS 7,源代码编译 Redis 6.2.x)
CentOS 7 已 EOL,但现场大量存量机器。不要在线升级大版本,先固化内核与 glibc 版本,Redis 选 6.2 LTS,足够稳定。
3.1 OS 准备与内核调优
# 基础与构建工具
yum install -y epel-release git gcc make wget tar numactl chrony tuned
systemctl enable --now chronyd
# 关闭 THP(CentOS7)
cat > /etc/systemd/system/disable-thp.service <<'EOF'
[Unit]
Description=Disable Transparent Huge Pages
After=sysinit.target
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'echo never > /sys/kernel/mm/transparent_hugepage/enabled'
ExecStart=/bin/bash -c 'echo never > /sys/kernel/mm/transparent_hugepage/defrag'
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload && systemctl enable --now disable-thp
# ulimit
echo -e '* soft nofile 1000000\n* hard nofile 1000000' >> /etc/security/limits.conf
# sysctl(网络/内存)
cat > /etc/sysctl.d/99-redis.conf <<'EOF'
vm.overcommit_memory = 1
net.core.somaxconn = 1024
net.ipv4.tcp_max_syn_backlog = 4096
net.ipv4.ip_local_port_range = 10000 65535
net.ipv4.tcp_tw_reuse = 1
net.core.netdev_max_backlog = 250000
fs.file-max = 2000000
EOF
sysctl --system
3.2 构建与安装 Redis
# 建 redis 用户
useradd -r -s /sbin/nologin redis
# 源码获取与编译(jemalloc)
cd /opt && wget http://download.redis.io/releases/redis-6.2.14.tar.gz
tar xzf redis-6.2.14.tar.gz && cd redis-6.2.14
make BUILD_WITH_SYSTEM_MALLOC=no MALLOC=jemalloc -j$(nproc)
make install PREFIX=/usr/local/redis-6.2.14
ln -s /usr/local/redis-6.2.14 /usr/local/redis
mkdir -p /etc/redis /var/lib/redis /var/log/redis
chown -R redis:redis /var/lib/redis /var/log/redis
3.3 多实例配置(同机跑两实例示例)
/etc/redis/redis-7001.conf(7002 类似改端口)
port 7001
cluster-enabled yes
cluster-config-file nodes-7001.conf
cluster-node-timeout 5000
protected-mode no
bind 0.0.0.0
daemonize yes
pidfile /var/run/redis-7001.pid
dir /var/lib/redis/7001
logfile /var/log/redis/redis-7001.log
appendonly yes
appendfsync everysec
no-appendfsync-on-rewrite yes
aof-use-rdb-preamble yes
# 内存策略:购物车是易失数据 + 强 TTL,避免误删核心键
maxmemory 40gb
maxmemory-policy volatile-lru
# 网络
tcp-backlog 1024
timeout 0
# 安全
requirepass <YOUR_STRONG_PASS>
masterauth <YOUR_STRONG_PASS>
目录与权限:
for p in 7001 7002; do
mkdir -p /var/lib/redis/$p
chown -R redis:redis /var/lib/redis/$p
done
systemd:
cat > /etc/systemd/system/redis@.service <<'EOF'
[Unit]
Description=Redis on port %i
After=network.target
[Service]
Type=forking
User=redis
Group=redis
ExecStart=/usr/local/redis/bin/redis-server /etc/redis/redis-%i.conf
ExecStop=/usr/local/redis/bin/redis-cli -p %i -a <YOUR_STRONG_PASS> shutdown
Restart=always
LimitNOFILE=1000000
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now redis@7001 redis@7002
防火墙/安全组(内网放行):
# 服务端口与总线端口
for p in 7001 7002 7003 7004 7005 7006; do
firewall-cmd --permanent --add-port=${p}/tcp
firewall-cmd --permanent --add-port=$((p+10000))/tcp
done
firewall-cmd --reload
3.4 创建集群(3 主 3 从)
假设 3 台物理机:10.0.0.11(7001/7002), 10.0.0.12(7003/7004), 10.0.0.13(7005/7006)。
/usr/local/redis/bin/redis-cli -a <YOUR_STRONG_PASS> --cluster create \
10.0.0.11:7001 10.0.0.12:7003 10.0.0.13:7005 \
10.0.0.11:7002 10.0.0.12:7004 10.0.0.13:7006 \
--cluster-replicas 1
验证:
redis-cli -c -h 10.0.0.11 -p 7001 -a <PASS> cluster info
redis-cli -c -h 10.0.0.11 -p 7001 -a <PASS> cluster nodes
3.5 持久化与冷备
AOF everysec + aof-use-rdb-preamble yes(重启更快)。
每晚低谷 BGSAVE,将 RDB 异地(COS/S3)冷备;每小时保留最近 3 份。
不要在峰值触发 BGREWRITEAOF,用 auto-aof-rewrite-min-size 与 auto-aof-rewrite-percentage 控制在低峰窗口。
4. 应用接入与原子语义
4.1 Spring Boot(Lettuce Cluster)示例
@Bean
public RedisClusterClient redisClusterClient() {
List<RedisURI> uris = Arrays.asList(
RedisURI.Builder.redis("10.0.0.11", 7001).withPassword("<PASS>".toCharArray()).build(),
RedisURI.Builder.redis("10.0.0.12", 7003).withPassword("<PASS>".toCharArray()).build(),
RedisURI.Builder.redis("10.0.0.13", 7005).withPassword("<PASS>".toCharArray()).build()
);
RedisClusterClient client = RedisClusterClient.create(uris);
client.setDefaultTimeout(Duration.ofMillis(200)); // 小超时避免堆积
return client;
}
4.2 Lua:加入购物车 + 事件投递(同槽内原子)
关键:cart:{uid} 与 stream:order:{uid} 共享 {uid} tag,落同槽可原子。
-- KEYS[1] = cart:{uid}, KEYS[2] = stream:order:{uid}
-- ARGV: sku, qty, ttlSeconds, eventJson
local qty = tonumber(ARGV[2])
redis.call('HINCRBY', KEYS[1], ARGV[1], qty)
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
local id = redis.call('XADD', KEYS[2], '*', 'evt', ARGV[4])
return id
4.3 Lua:下单幂等 + 预扣库存
-- KEYS[1]=order:idem:{uid}:{token} ; KEYS[2]=stock:{sku}
-- ARGV[1]=ttlSec ; ARGV[2]=needQty
if redis.call('SET', KEYS[1], '1', 'NX', 'EX', ARGV[1]) then
local cur = tonumber(redis.call('GET', KEYS[2]) or '0')
if cur >= tonumber(ARGV[2]) then
redis.call('DECRBY', KEYS[2], tonumber(ARGV[2]))
return 1 -- 成功
else
return -1 -- 库存不足
end
else
return 0 -- 幂等命中(重复下单)
end
4.4 Streams 消费(Java 片段)
// group = g-order, consumer = c-1
redis.xgroupCreate(XReadArgs.StreamOffset.from(streamKey, "0-0"), "g-order", XGroupCreateArgs.mkstream(true));
while (true) {
List<StreamMessage<String, String>> msgs =
redis.xreadgroup(Consumer.from("g-order","c-1"),
XReadArgs.StreamOffset.lastConsumed(streamKey));
for (var m : msgs) {
try {
// 处理事件...
redis.xack(streamKey, "g-order", m.getId());
} catch (Exception e) {
// 不 ack,pending 由重试器认领
}
}
}
5. 重点优化清单(黑五向)
5.1 Redis 配置
cluster-node-timeout 5000:大量流量时心跳易抖;5s 容错更稳。
tcp-keepalive 60:减少空闲连接假死。
hash-max-ziplist-entries 256 / hash-max-ziplist-value 512:购物车 Hash 更适配。
no-appendfsync-on-rewrite yes:AOF 重写不阻塞 fsync(峰值强烈建议)。
latency-monitor-threshold 100:快速定位毛刺源头。
5.2 OS/IO
- NVMe 调度器 none(或 noop),确认 cat /sys/block/nvme*/queue/scheduler。
- numactl --interleave=all 启动(跨 NUMA 均衡内存)。
- transparent_hugepage=never 已处理,避免停顿。
5.3 客户端与连接池
- 并发多路复用(Lettuce):减少连接数但提高吞吐。
- 超时策略:connectTimeout=100ms,commandTimeout=200ms,重试幂等命令,非幂等不重试。
- 「跨槽管道」坚决禁用;键 tag 设计是根本。
5.4 热键与分片
购物车热点用户可能集中,Hash 字段上可轻量分片:cart:{uid}:{bucket}(如对 sku 哈希后分 2–4 桶),Lua 同槽处理即可。
定期 CLUSTER KEYSLOT 自查模型变更是否破坏了同槽约束。
6. 压测与实测结果(节选)
6.1 redis-benchmark(单节点视角)
redis-benchmark -h 10.0.0.11 -p 7001 -a <PASS> \
-t set,hgetall -n 1000000 -c 2000 -P 16 --cluster
| 指标 | 值 |
|---|---|
| 峰值 QPS(cluster 总) | ~1.2–1.4 M ops/s |
| 平均延迟 | 0.85 ms |
| 99p | 2.1 ms |
| 99.9p | 4.8 ms |
7. 踩坑复盘(当夜真实故障)
7.1 坑一:跨槽 Lua 导致原子性失效(隐蔽)
某个功能分支把事件流 key 写成了 stream:order:<uid>(缺少 {} 标签)。高并发下偶发 -CROSSSLOT,客户端重试后出现操作只有部分成功(如加车成功但事件未入流),最终演化成丢单。
现场修复:
1)紧急灰度脚本改为 stream:order:{uid};
2)在 CI 加「ClusterKeysSlot 快照测试」:对新增 key 自动校验 keys-slot 一致性;
3)对关键 Lua 增加 EVALSHA 参数校验,发现跨槽直接 failfast,上报报警。
7.2 坑二:总线端口未完全放行导致故障转移慢
我们只放行了 7001–7006,忘记了 17001–17006 的集群总线端口。单节点 I/O 抖动时,心跳异常,主从选举被拖慢。
现场修复:立刻放行总线端口并在 Ansible 角色里固化规则;在启动前加 redis-cli cluster info 连续健康检查。
7.3 坑三:AOF 重写碰上高峰
自动阈值触发 BGREWRITEAOF,磁盘写放大叠加,P99 短暂抬升。
现场修复:
打开 no-appendfsync-on-rewrite yes,并将 auto-rewrite 调到凌晨低谷(配合运维窗口)。
对 AOF 文件做分段归档,避免单文件过大。
8. 运营与可观测(黑五必备)
监控(Prometheus + Grafana + redis_exporter):
instantaneous_ops_per_sec、connected_clients、cluster_slots_pfail/fail、aof_current_size、aof_rewrite_in_progress、latency > 100ms 计数、rejected_connections。
告警门槛:
cluster_state != ok 立刻 P1;aof_rewrite_in_progress 超 15 分钟 P2;ops/sec 波谷 + pending 积压上升 P1。
Runbook:
节点置维护:CLUSTER FAILOVER TAKEOVER(仅在必要时)、CLUSTER FORGET/MEET 扩缩容;
紧急上线 Lua:SCRIPT LOAD + EVALSHA;
快速回滚键模型:预置双写开关,20% 灰度回放。
9. 扩容与变更(滚动无损)
1)新加两节点(1 主 1 从),--cluster add-node 加主;
2)--cluster reshard 迁移若干槽位(优先迁移热点槽);
3)cluster-require-full-coverage yes 保守策略(默认),迁移期间保持服务可用;
4)流量观测 10 分钟后,再为新主补从。
10. 我用的标准化清单(可抄)
键命名
- cart:{uid}(Hash, TTL=7d)
- order:idem:{uid}:{token}(String, TTL=10m)
- stock:{sku}(String)
- stream:order:{uid}(Streams, 分组 g-order)
最小权限:应用只给必要命令白名单(ACL,Redis 6+)。
低峰窗口:AOF 重写、RDB 冷备、CRON 合并。
压测脚本:预热 10 分钟再计量;分离只读/只写/混合三档。
11. 黑五 23:58 的那口气
重新打上 {uid} 标签、放行总线端口、把重写挪到后半夜后,延迟曲线像被手顺过的褶皱,慢慢铺平。凌晨一点,订单墙稳稳地刷着新单,我用手背蹭了蹭有点凉的机房桌面,给自己倒了杯偏甜的自动售货机咖啡。那一瞬间我确切知道:Redis Cluster + 正确的键模型 + 原子语义 + 谨慎的持久化,足以把黑五的洪水导入有序的渠道里。
如果你也在值夜班,愿这份笔记少让你踩几个我踩过的坑,帮你在最需要的两分钟里,迅速做对三件事。
附:完整命令速查(便于粘贴)
# 查看集群健康
redis-cli -c -h 10.0.0.11 -p 7001 -a <PASS> cluster info
# 重新分片(交互式)
redis-cli --cluster reshard 10.0.0.11:7001 -a <PASS>
# 添加从节点
redis-cli --cluster add-node 10.0.0.13:7006 10.0.0.11:7001 --cluster-slave -a <PASS>
# Streams pending 处理
redis-cli -a <PASS> XPENDING stream:order:{uid} g-order
redis-cli -a <PASS> XCLAIM stream:order:{uid} g-order <consumer> <ms> <id> ...
# 延迟诊断
redis-cli -a <PASS> LATENCY LATEST
丢单本质是「写成功的事实未形成可消费的事实」。
用 Redis Cluster 做好同槽原子、幂等令牌、可靠事件流,再加上一点持久化与总线的细节,黑五高并发也能稳稳兜住。