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

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

发布人:Minchunlin 发布时间:2025-09-22 09:47 阅读量:612


去年黑五,我值守我们香港机房的电商集群。夜里 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 做好同槽原子、幂等令牌、可靠事件流,再加上一点持久化与总线的细节,黑五高并发也能稳稳兜住。

目录结构
全文