系列第 2 篇。上一篇用 4 台云主机搭了鸿蒙多设备任务接续的后端,1 万长连接零丢失,但留了一个单点:Redis。这一篇把它换成主从 + 三哨兵,然后在 1 万连接压测中直接停掉主库,看真实表现。

环境与版本

项目版本 / 规格
主机4 台华为云 ECS,8 vCPU / 16 GiB,Ubuntu 24.04.4
Redis7.0.15(1 主 2 从 + 3 哨兵)
Node.js20.20.2
Nginx1.24.0(WebSocket 反代,proxy_read_timeout 600s)
端侧鸿蒙云手机 ALN-AL00 / HarmonyOS 5.0.1 / API 17
压测自研 Node 长连接客户端,1 万并发
哨兵参数down-after-milliseconds 3000 / failover-timeout 60000

一、单点在哪里,为什么必须先解决它

上一篇的架构里,Redis 只有一台(192.168.0.155),却承担了两件不能丢的事:

  • 设备影子:每台设备当前挂在哪个节点、最后一笔任务态是什么。设备重连后要靠它补齐断线期间的状态。
  • 跨节点消息总线:A 设备连在 node1,B 设备连在 node3,消息必须经 Redis Pub/Sub 才能到达。

它一挂,整个集群立刻退化成三座孤岛:同节点设备还能通信,跨节点全部中断,重连的设备也读不到自己的影子。长连接服务可以容忍慢,不能容忍"状态消失"。

所以这一篇的目标很明确:Redis 主库宕机时,正在进行的任务接续不能丢状态,恢复时间要可量化。

二、部署:一主两从 + 三哨兵

4 台机器的分工不变,Redis 变成一主两从,哨兵和 Node 服务同机部署:

主机内网 IPRedis 角色其他
0001192.168.0.155初始 masterNginx + Node + 哨兵
0002192.168.0.150replicaNode + 哨兵
0003192.168.0.242replicaNode + 哨兵

从库只需两行配置:

replicaof 192.168.0.155 6379
save ""            # 关掉 RDB,影子服务不需要落盘

哨兵配置(三台一致):

port 26379
bind 0.0.0.0
protected-mode no
sentinel monitor mymaster 192.168.0.155 6379 2
sentinel down-after-milliseconds mymaster 3000
sentinel failover-timeout mymaster 15000
sentinel parallel-syncs mymaster 1

quorum=2 表示至少两个哨兵认为主库下线才发起故障转移,这是防误判的关键——单哨兵网络抖动就可能触发一次无谓切换。

部署完验证:

role:master
connected_slaves:2
slave0:ip=192.168.0.242,port=6379,state=online,offset=28,lag=0
slave1:ip=192.168.0.150,port=6379,state=online,offset=28,lag=1

三、踩坑:Ubuntu 24.04 里没有 redis-sentinel.service

这是本次部署最意外的坑。按惯例 systemctl enable --now redis-sentinel 之后,服务状态是 inactive,journalctl -u redis-sentinel 显示 No entries,26379 端口没人监听——不是启动失败,是这个 unit 根本没被安装。

Ubuntu 24.04 的 redis-server 包不带 sentinel 的 systemd 单元,只能自己写一个:

[Unit]
Description=Redis Sentinel for Harmony shadow
After=network.target

[Service]
Type=simple
User=redis
Group=redis
ExecStart=/usr/bin/redis-server /etc/redis/sentinel.conf --sentinel
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target

两个细节必须注意,否则哨兵会"看着像活的,其实不工作":

  1. 配置文件必须可写。哨兵会把当前 master、已发现的 slave 和其他哨兵回写到 sentinel.conf。如果文件属主是 root 而服务以 redis 用户运行,日志里会出现配置保存失败,哨兵之间互相发现不了。chown redis:redis /etc/redis/sentinel.conf 之后再启动,日志里才出现 Sentinel new configuration saved on disk。
  2. protected-mode no 和 bind 0.0.0.0 要一起改。只改 bind 不改保护模式,跨机哨兵依然连不上。

验证方式不是看进程,而是问它要主库地址:

redis-cli -p 26379 sentinel get-master-addr-by-name mymaster
# 192.168.0.155
# 6379

四、服务端改造:连接串换成哨兵发现

应用侧不能再写死 Redis IP,否则主库切换后所有写操作都会打到旧主上。ioredis 原生支持哨兵:

const redisOpts = {
  sentinels: [
    { host: '192.168.0.155', port: 26379 },
    { host: '192.168.0.150', port: 26379 },
    { host: '192.168.0.242', port: 26379 },
  ],
  name: 'mymaster',
};
const pub = new Redis(redisOpts);   // 写
const sub = new Redis(redisOpts);   // 订阅

三个哨兵地址全写上,任意一个活着就能发现主库。切换时 ioredis 会收到哨兵通知并自动重连新主,业务代码不用改。

五、实验:1 万连接压测中停掉主库

压测方式和上一篇一致:1 万模拟设备(5 千对 phone/tablet),每 5 秒一轮任务接续消息,全程经 Nginx。跑到第 35 秒,直接 systemctl stop redis-server 停掉主库。

哨兵日志给出了完整时间线:

22:51:05        停主库
22:51:10.187    +sdown master          主观下线(down-after 3000ms)
22:51:10.245    +odown master quorum 2/2  客观下线
22:51:10.416    +failover-state-send-slaveof-noone 192.168.0.242
22:51:11.257    +failover-state-reconf-slaves
22:51:12.378    +failover-end / +switch-master → 192.168.0.242

从停库到新主接管,一共 7.4 秒,其中 3 秒是配置里的主观下线判定窗口,真正的选主和提升只有 2 秒左右。

客户端侧的数据更有意思:

指标数值
在线连接10,000
消息总数95,000
消息丢失0
WebSocket 重连次数0
RTT P50192ms
RTT P955,692ms
RTT P995,803ms
RTT 最大值5,855ms

六、怎么解读这组数据

1. Redis 故障不会让 WebSocket 断开。 重连次数是 0,因为设备连的是 Node 节点,不是 Redis。很多方案把"存储高可用"和"接入层高可用"混在一起讨论,其实它们是独立的两层:存储挂了,接入层照样能保持连接。

2. 消息不是丢了,是排队了。 P50 还是 192ms,说明故障窗口之外的消息完全正常;P95/P99 冲到 5.7~5.8 秒,正好是切换窗口内那一批消息——它们在 ioredis 的重连队列里等到新主接管,然后被一次性发出去。对任务接续这种"最终一致比即时到达更重要"的场景,排队远比丢弃友好。

3. 7.4 秒的切换窗口 ≈ 5.8 秒的尾部延迟。 两者基本吻合,说明这套链路里没有额外的重试放大。想压缩尾部延迟,优先调 down-after-milliseconds(比如降到 1000ms),代价是对网络抖动更敏感。

4. 影子还在。 切换期间如果有设备重连,读到的影子来自从库副本,最多落后一个复制延迟(本例 lag=0~1)。这就是主从相对单机的本质提升:状态可恢复,而不只是服务可恢复。

七、端侧视角:Redis 挂了,鸿蒙客户端会怎样

上面所有数字都是服务端视角。但真正决定体验的是云手机上的 ArkTS 客户端。我在同一套环境里用真机云手机跑了一遍,把端侧行为拆成三件事。

7.1 连接层:不受影响

鸿蒙端用的是系统内置网络库:

import webSocket from '@ohos.net.webSocket';

this.ws = webSocket.createWebSocket();
this.ws.connect(`ws://1.92.107.89/ws?device=hm-phone-01&type=phone`);

它连的是 Nginx 的 80 端口,Nginx 再转到 Node 节点。Redis 在 Node 后面的第二层,端侧完全感知不到。

实测印证:kill -9 掉 Node 节点时,云手机立刻收到 close + error(因为接入层断了);而只停 Redis 主库时,云手机 WebSocket 一直保持连接、没有触发任何 close 事件。

这给了一个明确的架构结论:

故障端侧表现需要客户端做什么
Redis 主库宕机连接不断,消息延迟升高显示"同步中",不要报错弹窗
Node 节点宕机连接断开触发重连 + 拉影子
Nginx 宕机连接断开同上

如果把两类故障都当成"网络异常"处理,用户会看到大量无意义的错误提示。

7.2 消息层:延迟升高,但不丢

哨兵切换的 7.4 秒里,P99 冲到 5.8 秒。端侧看到的是:点击"发送"后日志迟迟不出现"收到 ack"。

这是我在云手机上的真实截图(节点被 kill 的场景,断线与恢复路径一致):

云手机断线后重连并拉取影子

12:18:45 已连接 hm-phone-01
12:18:48 收到 seq=305 来自=hm-tablet-01 [缺口1条->拉影子]
12:18:49 影子恢复: {"task":"tablet-task-305",...,"node":"node2","state":"online"}

注意最后那个 "node":"node2" —— 影子是从切换后的节点读出来的。这证明 Redis 层的状态接管对端侧是透明的:客户端不需要知道数据存在哪,只要重连后拉一次影子,拿到的就是最新状态。

7.3 体验层:三段式状态,别用二值判断

基于上面两点,鸿蒙端的连接状态不该只有「已连接/未连接」两个值。我改成了三态:

enum SyncState {
  ONLINE,      // 连接正常,消息即时
  DEGRADED,    // 连接在,但消息延迟高(存储层故障)
  OFFLINE      // 连接断开,需要重连
}

DEGRADED 的判断依据是 ACK 超时:发出消息后 3 秒没收到 ack,就切到降级态,UI 显示「同步中…」而不是「发送失败」。

这个状态机是这次实测倒逼出来的——如果只有二值判断,Redis 切换的 7.4 秒里,用户会看到「发送失败」,然后重复点击,产生大量重复消息。

八、旧主回归:不改配置就是双主

这是最容易被忽略的一步。故障后 192.168.0.242 已经是新主,如果直接把 0001 的 Redis 起回来,它的配置里没有 replicaof,会以主库身份启动——集群里立刻出现两个可写的主,客户端分别连到不同主库,影子数据从此分叉。

正确做法是先改配置再启动:

sed -i '/^replicaof/d' /etc/redis/redis.conf
echo 'replicaof 192.168.0.242 6379' >> /etc/redis/redis.conf
systemctl start redis-server

起来之后它是新主的从库,哨兵日志里能看到 +convert-to-slave 和 -sdown slave。

生产环境建议把这一步写进故障恢复 SOP,或者用哨兵的 notification-script / 配置管理工具自动完成,别指望运维半夜还记得这条命令。

九、结论

回到本篇的目标:Redis 主库宕机时,任务接续不丢状态,恢复时间可量化。

实测答案是:

  • 一主两从 + 三哨兵,7.4 秒完成切换,其中 3 秒是主动配置的下线判定窗口;
  • 1 万长连接全程零重连、零消息丢失;
  • 代价是切换窗口内消息尾部延迟 5.8 秒,属于排队而非丢失;
  • 真正的风险点在切换之后——旧主必须以从库身份回归,否则双主脑裂比宕机更难收拾;
  • 端侧要区分两类故障:存储故障表现为延迟,接入故障才表现为断连,用三态(在线/降级/离线)而不是二值判断。

下一篇换个方向:之前所有压测的 P50 都在 200ms 上下,但服务端 CPU 只有 3%。这一篇把压测客户端拆成多进程,看看 2 万设备时到底是谁在拖后腿。


环境:4 × 华为云 x3e.8u.16g(8C16G,Ubuntu 24.04.4),Redis 7.0.15,Node 20.20.2,Nginx 1.24.0,2026-11 实测。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐