在香港机房跑 Ubuntu,把 Redis 持久化用到“稳、准、狠”:如何堵住购物车数据丢失的所有坑

那天是周六凌晨 2:17,香港机房业务的Grafana 的曲线忽然像心电图一样抖了一下:购物车写入 QPS 从 18k 掉到 0。值班同事问我“是不是有人清空促销了?”我没吭声,SSH 一路飞进宿主,docker ps 里 Redis 重启过——AOF 没开,RDB 默认间隔快照,容器卷还挂错了目录。几分钟后,客服工单里第一条就写着:“我刚加的东西不见了。”
这篇文章,就是我在那次事故之后,如何在香港服务器(Ubuntu)上把 Redis 持久化、系统参数、容器挂载、监控告警一网打尽的完整实操。写给凌晨会被电话吵醒的你。
环境画像与硬件清单(真实机房参数)
| 项目 | 参数 |
|---|---|
| 机房 | 香港葵涌,双路市电 + UPS,冷通道封闭 |
| 服务器 | AMD EPYC 7402P(24C/48T)/ 128GB DDR4 |
| 存储 | 2× NVMe SSD(PCIe 4.0,3.5GB/s 顺序写),RAID1(mdadm) |
| 网卡 | 2× 10GbE(Bonding active-backup) |
| 系统 | Ubuntu 22.04 LTS(5.15 内核) |
| 文件系统 | ext4(noatime,nodiratime,barrier=1)挂载在 /data |
| Redis | 7.2.x(生产)+ redis-exporter(监控) |
| 部署方式 | Docker + docker-compose(生产),Systemd(单机演练) |
| 网络延迟 | 港—广州 18–26ms,港—上海 35–45ms(实测区间) |
为什么选 NVMe RAID1?
AOF 的 everysec 需要稳定的 fsync 延迟,NVMe + RAID1 能把单盘抖动风险降下来;而 ext4 的 data=ordered 在断电时更可预期。
购物车的“失忆”是怎样发生的?
- 只用 RDB:save 900 1、300 10、60 10000 之类的默认配置,快照之间的窗口内写入崩溃即丢。
- 容器卷挂错:把 /data 挂到匿名卷或没做持久化 bind mount,容器重建就像“换了台新机”。
- 淘汰策略误用:设置了 maxmemory 且 allkeys-lru,热点 ok,但购物车不能被淘汰,否则就是隐性丢失。
- 磁盘/内核不友好:THP(透明大页)未关、I/O 调度器不合适、vm.dirty_* 太激进,都会让 fsync 抖动或写放大。
铁律:购物车是“弱一致”但不能“弱存在”。我的原则是AOF everysec + RDB 作为冷备,专用实例配noeviction,应用端做好错误与重试。
目标架构(能扛断电、能抗抖动、能自动接管)
- Redis 主从 + Sentinel:同城三哨兵自动切换(全部在香港机房内,跨机房做冷备/异地灾备)。
- 持久化组合拳:AOF(appendfsync everysec + aof-use-rdb-preamble yes)+ RDB(定期 save)。
- 专用实例:购物车独享 Redis,maxmemory-policy noeviction,避免误删。
- 独立数据盘:/data/redis 独立 NVMe 分区 + 合理挂载参数。
- 监控告警:redis-exporter + Prometheus + Grafana,配高优先级告警(AOF rewrite 失败、延迟激增、内存逼近阈值)。
系统与文件系统调优(落地到命令)
# 1) 关闭透明大页
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo "never" | sudo tee /sys/kernel/mm/transparent_hugepage/defrag
echo -e 'if test -f /sys/kernel/mm/transparent_hugepage/enabled; then echo never > /sys/kernel/mm/transparent_hugepage/enabled; fi' | \
sudo tee -a /etc/rc.local && sudo chmod +x /etc/rc.local
# 2) I/O 调度器(NVMe 一般用 none)
for q in /sys/block/nvme*/queue/scheduler; do echo none | sudo tee $q; done
# 3) sysctl
sudo tee /etc/sysctl.d/99-redis.conf >/dev/null <<'EOF'
vm.swappiness = 1
vm.overcommit_memory = 1
net.core.somaxconn = 65535
fs.file-max = 2000000
EOF
sudo sysctl --system
# 4) ulimit(Systemd 里配 LimitNOFILE,Docker 用 --ulimit)
/etc/fstab(/dev/md0 是 NVMe RAID1):
/dev/md0 /data ext4 noatime,nodiratime,barrier=1 0 2
两种部署方式:Docker(生产推荐)与 Systemd(演练/备用)
1) docker-compose(含 Sentinel 与 Exporter)
version: "3.9"
services:
redis:
image: redis:7.2
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
ports: ["6379:6379"]
ulimits:
nofile: 1000000
volumes:
- /data/redis:/data
- ./redis.conf:/usr/local/etc/redis/redis.conf:ro
sysctls:
net.core.somaxconn: "65535"
restart: always
sentinel:
image: redis:7.2
command: ["redis-sentinel", "/usr/local/etc/redis/sentinel.conf"]
depends_on: [redis]
ports: ["26379:26379"]
volumes:
- ./sentinel.conf:/usr/local/etc/redis/sentinel.conf:ro
restart: always
exporter:
image: oliver006/redis_exporter:v1.62.0
command: ["--redis.addr=redis://redis:6379"]
ports: ["9121:9121"]
restart: always
redis.conf(精简重点):
bind 0.0.0.0
protected-mode yes
port 6379
requirepass "S3cureP@ss"
dir /data
# 持久化
save 900 1
save 300 10
save 60 10000
stop-writes-on-bgsave-error yes
rdbcompression yes
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
no-appendfsync-on-rewrite yes
aof-use-rdb-preamble yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 256mb
# 内存与淘汰
maxmemory 24gb
maxmemory-policy noeviction
# 其他
tcp-keepalive 300
lua-time-limit 5000
sentinel.conf(单哨兵示例,生产需 3 个不同主机):
port 26379
dir /data
sentinel monitor cart-master 10.0.0.11 6379 2
sentinel auth-pass cart-master S3cureP@ss
sentinel down-after-milliseconds cart-master 5000
sentinel failover-timeout cart-master 60000
sentinel parallel-syncs cart-master 1
要点:/data/redis 一定是宿主机的真实目录(如 /data/redis),不要用匿名卷;AOF 与 RDB 都落在这个目录,容器重建不丢。
2) Systemd(单机服务)
/etc/systemd/system/redis.service:
[Unit]
Description=Redis In-Memory Data Store
After=network.target
[Service]
ExecStart=/usr/bin/redis-server /etc/redis/redis.conf --supervised systemd
ExecStop=/bin/kill -s TERM $MAINPID
User=redis
Group=redis
LimitNOFILE=1000000
TimeoutStartSec=0
Restart=always
[Install]
WantedBy=multi-user.target
数据模型与原子性:购物车键设计
- Key 结构:cart:{user_id}(Hash)
- 字段:{sku_id} -> json(含数量、价格快照、更新时间)
- TTL:更新后 EXPIRE cart:{user_id} 7d(未结算 7 天自动清理)
- 原子更新:WATCH/MULTI/EXEC 或 Lua;我更喜欢 Lua,减少往返
Lua 原子脚本(增减商品 + 刷新 TTL):
-- KEYS[1] = cart:{user_id}
-- ARGV[1] = sku_id
-- ARGV[2] = json_payload
-- ARGV[3] = ttl_seconds
local exists = redis.call('HEXISTS', KEYS[1], ARGV[1])
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
return exists
Python 接入示例(redis-py 5.x):
import json, time
import redis
r = redis.Redis(host='10.0.0.11', port=6379, password='S3cureP@ss', socket_timeout=0.2)
ADD_LUA = r.register_script("""
local exists = redis.call('HEXISTS', KEYS[1], ARGV[1])
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
return exists
""")
def add_to_cart(uid, sku_id, qty, price, ttl=604800):
key = f"cart:{uid}"
payload = json.dumps({"sku": sku_id, "qty": qty, "price": price, "ts": int(time.time())}, separators=(',',':'))
try:
ADD_LUA(keys=[key], args=[sku_id, payload, str(ttl)])
except redis.exceptions.RedisError as e:
# noeviction 会在内存不足时报错,这里要降级/重试/落盘日志
raise
def get_cart(uid):
key = f"cart:{uid}"
items = r.hgetall(key)
return {k.decode(): json.loads(v) for k, v in items.items()}
为什么选 Hash? 单个 key 管理整车更利于 EXPIRE 控制;另外 Hash 的 RDB/AOF 编码比海量小 key 更紧凑。
性能与持久化的“账本”:实测对比
环境为上面的 NVMe + Ubuntu;对比不同 fsync 策略的 10 分钟压测平均值(redis-benchmark -t set,get -q -n 2000000 -P 64,业务接近 SET/GET 模式,仅供参考):
| 模式 | p50 写延迟 | p99 写延迟 | QPS | 崩溃恢复数据丢失窗口 |
|---|---|---|---|---|
| 仅 RDB(默认保存) | 0.4ms | 3.1ms | 220k | 1–900s(取决于 save)❌ |
AOF everysec |
0.7ms | 5.6ms | 190k | ≤ 1s(通常 0–1s)✅ |
AOF always |
2.8ms | 18ms | 95k | 0(最稳,但昂贵)✅ |
AOF everysec + no-appendfsync-on-rewrite |
0.6ms | 4.9ms | 200k | ≤ 1s ✅ |
结论:购物车首选 everysec,在 NVMe 上延迟成本可接受;AOF 重写期间开启 no-appendfsync-on-rewrite 能平滑尾延迟。
备份与回放:把“黑匣子”放到对象存储
- AOF 归档:每小时 rsync 最新 appendonly.aof* 到 /backup/redis/$(date +%F%H)
- 异地备份:rclone 推送到对象存储(如 S3 兼容端点),保留 7–14 天版本
- 回放演练:每周至少一次,把 AOF 拷到隔离环境,redis-check-aof --fix 后进行启动回放
示例 cron(/etc/cron.d/redis-backup):
5 * * * * root rsync -a --delete /data/redis/appendonly.aof* /backup/redis/hourly/
15 * * * * root rclone sync /backup/redis/hourly s3:hk-backup/redis/hourly --transfers 8
监控与告警(哪些指标必须盯)
- 持久化:redis_aof_last_cow_size_bytes、redis_aof_last_rewrite_duration_sec、redis_rdb_last_bgsave_status
- 延迟:latency: command(开启 Redis latency monitor)、Exporter 的 instantaneous_ops_per_sec
- 内存:used_memory、mem_fragmentation_ratio、evicted_keys_total(应为 0)
- 副本延迟:slave_repl_offset 与主的 master_repl_offset 差值
告警建议:
- AOF 重写失败(立即)
- 5 分钟内 p99 > 20ms(高优)
- 复制延迟 > 1s 持续 3 分钟(中优)
- evicted_keys_total > 0 (致命)
演练清单(上生产前/每季度跑一遍)
- 断电模拟:kill -9 $(pidof redis-server),重启后校验购物车样本是否完整。
- AOF 损坏修复:redis-check-aof --fix appendonly.aof,验证能否平滑恢复。
- 主从切换:停主,确认 Sentinel 提升从库;业务侧连接串是否用哨兵或代理。
- 容器重建:docker compose down && up -d,确认数据未丢(卷挂载正确)。
- 磁盘满写:模拟磁盘用尽,验证告警与降级策略。
线上踩过的坑与现场解法
坑 1:容器卷挂匿名卷
现象:重建后购物车全部清零。
解法:强制 bind mount 到宿主 /data/redis;CI 里增加“匿名卷禁用”检查。
坑 2:AOF 重写卡顿尾延迟
现象:p99 偶发飙到 100ms+。
解法:开启 no-appendfsync-on-rewrite yes + 调整 auto-aof-rewrite-min-size 到 256–512MB,确保重写不要太频繁;同时保证 NVMe 剩余寿命与写放大监控。
坑 3:开启了 allkeys-lru
现象:流量高峰下购物车“随机”消失。
解法:购物车专用实例,noeviction;内存阈值定为数据峰值的 1.5×,应用侧遇到 OOM command not allowed 走降级与重试。
坑 4:THP 未关 + 调度器不当
现象:fsync 抖动,AOF 延时不稳。
解法:关闭 THP,NVMe 调度器用 none,稳定即正义。
坑 5:只用 RDB
现象:快照间隙丢数据。
解法:AOF everysec 是底线,RDB 做冷备与快速冷启动。
上线检查表(贴墙上那种)
- /data/redis 在 NVMe 分区,noatime,nodiratime,barrier=1
- appendonly yes + appendfsync everysec + aof-use-rdb-preamble yes
- maxmemory-policy noeviction,购物车实例独享
- THP 关闭、I/O 调度器 none、vm.overcommit_memory=1
- ulimit ≥ 1,000,000;somaxconn=65535
- Sentinel ≥ 3 节点,业务连接哨兵或中间层(例如 twemproxy/代理)
- Exporter 上报 + 告警规则到位
- 备份与回放演练通过
- 压测报告留存(含 AOF 重写期间的 p99)
事故那晚,我们在 3:40 又冲了一杯咖啡,回放了 AOF,补齐了 0:00–2:00 的全部购物车。第二天白天,我把“AOF everysec + 专用实例 + 演练清单”贴在机柜门上。再后来,我们按这套标准把 Redis 购物车跑过了几次大促,最高峰的那天,AOF 重写把 p99 顶到了 7ms,但再也没人跟我说“我刚加的东西不见了”。
如果你也在香港机房里跟延迟和丢数赛跑,愿这份笔记能让你的凌晨安静一点。
附:最小可运行示例(一步起服务)
# 准备目录
sudo mkdir -p /data/redis && sudo chown -R 999:999 /data/redis
# 写入 redis.conf / sentinel.conf 与上文一致
# 启动
docker compose up -d
# 健康检查
redis-cli -a 'S3cureP@ss' -h 127.0.0.1 ping
redis-cli -a 'S3cureP@ss' hset cart:42 1001 '{"sku":1001,"qty":2,"price":199,"ts":1710000000}'
docker compose restart redis
redis-cli -a 'S3cureP@ss' hgetall cart:42 # 应该还在