生产事故排查:Redis 宕机导致补偿队列消费线程死亡,通话录音未上送业务平台

一、事故背景

某呼叫中心平台通过外呼系统进行电话营销,通话结束后需要将录音文件和通话记录推送到上游业务平台。业务方反馈:部分拨号盘外呼的通话记录,录音文件在软交换侧已生成,但未成功上送到上游业务平台,导致业务平台侧无法关联到录音文件,影响后续的业务回执和质检流程。
经初步统计,受影响的通话记录在本地数据库中状态为"同步失败"(out_ward_result=3),失败原因记录为"操作失败"。同时发现这些记录的工单ID字段为空,与正常同步成功的记录存在明显差异。

二、业务链路梳理

排查的第一步是找到一条正常数据,完整梳理从通话结束到录音上送的业务链路。

正常数据流程

通话记录上送业务平台有两条路径:

路径 A(补偿队列,主路径):

复制代码
① 上游业务平台 → 三方接口调用服务 /getCallRecord 接口
   → 调用软交换接口查询通话记录
   → 软交换返回 size:0(通话刚结束,尚未生成录音)
   → 存入 Redis 缓存(key=uuid|callType|0, value=工单ID)

② 定时任务从软交换补全数据到本地库
   → 查询本地库中 hangup_case IS NULL 的记录
   → 再次调用软交换接口,此时返回 size:1(含录音地址)
   → 从 Redis 缓存读出工单ID,写入本地库
   → 加入 Redis 补偿队列

③ 补偿队列消费线程
   → 从队列取出记录ID
   → 查询本地库(含工单ID + 录音地址)
   → 调用上游业务平台接口推送
   → 成功 → 标记为已同步

路径 B(定时批量扫描,兜底):

复制代码
定时任务扫描本地库中"未同步"的记录
  → 通过 SQL JOIN 活动数据表获取工单ID
  → 调用上游业务平台接口推送

关键差异

路径 A 的工单ID 来自上游业务平台主动调用时传入,存入 Redis 缓存后写入本地库。路径 B 的工单ID 来自活动数据表 JOIN,但拨号盘外呼的活动数据ID固定为 0,JOIN 不到任何记录,工单ID 为空。
这意味着:路径 A 是拨号盘外呼推送业务平台的唯一有效路径,路径 B 兜底无效。

三、Redis 大 Key 排查与服务器健康检查

排查过程中一度怀疑 Redis 内存问题导致服务异常。通过 Redis Desktop Manager(RDM)和 redis-cli 完整验证,最终确认 Redis 内存健康,但在此过程中发现了一个关键线索。

1. Redis 内存指标解读

排查时通过 RDM 查看 Redis 的 Info → Memory 面板,以下是关键指标及其含义:

指标 值 含义
used_memory 913.51M Redis 进程实际使用的内存(包含数据、连接缓冲区、Lua 引擎等所有开销)
used_memory_dataset ~901M 存储键值对数据本身占用的内存(不含连接、缓冲区等开销)
used_memory_dataset_perc 98.69% 数据集内存占已用内存的比例(即 901M / 913M)
used_memory_overhead ~12.8M 非数据部分的开销(客户端连接缓冲区、Redis 内部数据结构等)
used_memory_peak 1.00G Redis 历史最高内存使用量(自启动以来的峰值)
used_memory_peak_perc 88.90% 当前内存占历史峰值的比例(即 913M / 1G)
total_system_memory 31.42G 服务器物理总内存
maxmemory 0 最大内存限制,0 表示未设置上限
maxmemory_policy noeviction 内存满时的淘汰策略,noeviction 表示拒绝写入而非淘汰旧键
mem_fragmentation_ratio 1.32 内存碎片率 = RSS物理内存 / Redis分配内存,>1 表示存在碎片
mem_allocator libc 内存分配器(jemalloc 通常比 libc 碎片率更低)

2. 误判澄清:used_memory_dataset_perc ≠ 服务器内存占用率

排查时 RDM 界面醒目地显示 used_memory_dataset_perc = 98.69%,第一反应是"Redis 内存快满了"。但实际上这是一个容易误导的指标:

  • used_memory_dataset_perc 衡量的是数据集占 Redis 已用内存的比例 ,反映的是"Redis 已用内存中有多少是纯数据、多少是开销",与服务器总内存无关
  • 真正反映服务器内存占用率的计算方式是:used_memory / total_system_memory = 913M / 31.42G ≈ 2.8%
  • 即使看物理内存占用(RSS),也只有 used_memory_rss ≈ 1.17G,占服务器总内存的 3.7%

结论:Redis 内存健康,服务器内存充裕,不存在内存压力。

3. used_memory_peak_perc 的正确解读

另一个容易误读的指标是 used_memory_peak_perc = 88.90%:

  • 这个指标的含义是当前内存使用量占历史峰值的比例 ,即 used_memory / used_memory_peak = 913M / 1G ≈ 88.90%
  • 它反映的是"当前负载是否接近历史最高水位",88.90% 说明当前处于历史高水位附近,但总量不到 1G,远未达到服务器极限
  • 如果 used_memory_peak 本身就很小(如本例 1G),即使 peak_perc 是 100% 也不代表有问题

4. 真正需要关注的配置风险

虽然内存不紧张,但发现两个配置隐患:

  • maxmemory = 0(未设置上限):Redis 不会因为内存过高而拒绝写入或淘汰数据,极端情况下可能吃满服务器内存导致 OOM。建议设置兜底值(如 2G)
  • maxmemory_policy = noeviction :即使设置了 maxmemory,达到上限后 Redis 的策略是拒绝所有写入 而非淘汰旧键。对于缓存场景建议用 allkeys-lru,对于队列场景需要单独评估

5. 大 Key 发现------补偿队列积压

在排查大 Key 时发现了一个直接指向根因的线索:

Key 类型 大小 说明
活动对象缓存 hash 7.4MB 存活动对象序列化数据,无 TTL
转写任务队列 list 4.3MB 待转写任务
活动批次缓存 hash 1.75MB 无 TTL
计数器缓存 hash 1.34MB 无 TTL
软交换同步状态 hash 1.06MB 7923 字段,无 TTL
补偿队列 list 205KB 积压 16804 条

补偿队列积压了 16804 条数据 ------正常情况下补偿队列应该被消费线程实时消费,积压量接近于零。16804 条积压说明补偿队列的消费线程已经停止工作很久了。这是排查过程中发现的第一个直接证据,将排查方向从"Redis 内存问题"转向了"消费线程是否存活"。

四、代码分析定位缺陷并修复

找到补偿队列消费线程的代码,分析后发现了一个致命缺陷。

旧代码(缺陷版本)

java 复制代码
@PostConstruct
public void synOffsetFromLocalCallRecordToZhyx() {
    Thread thread = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                Object value = redisService.getValueForList(CacheConstants.SYN_CALLRECORD_TO_ZHYX);
                if (null != value) {
                    Long callRecordId = (Long) value;
                    synCallrecordService.synOffsetFromLocalCallRecordToZhyx(callRecordId);
                }
                // 致命缺陷1: 没有 try-catch → Redis 异常直接杀死线程
                // 致命缺陷2: 没有 sleep → 队列空时 CPU 满速空转
            }
        }
    });
    thread.start();
}

getValueForList 底层是 redisTemplate.opsForList().leftPop(key),同步阻塞操作。Redis 宕机时抛出 RedisConnectionFailureException。

旧代码的两个致命缺陷:

  1. 没有 try-catch :Redis 异常直接跳出 while(true),线程永久死亡 。@PostConstruct 不会重新执行,补偿队列消费永久停止
  2. 没有 sleep:队列为空时以 CPU 满速空转,每次循环都尝试获取 Redis 连接,可能耗尽连接池

新代码(修复版本)

java 复制代码
@PostConstruct
public void synOffsetFromLocalCallRecordToZhyx() {
    Thread thread = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                try {
                    Object value = redisService.getValueForList(CacheConstants.SYN_CALLRECORD_TO_ZHYX);
                    if (null != value) {
                        Long callRecordId = (Long) value;
                        synCallrecordService.synOffsetFromLocalCallRecordToZhyx(callRecordId);
                    } else {
                        // 队列为空时休眠 1 秒,避免死循环空转导致 Redis 连接耗尽
                        Thread.sleep(1000L);
                    }
                } catch (InterruptedException e) {
                    logger.warn("补偿队列消费线程被中断,退出循环");
                    Thread.currentThread().interrupt();
                    break;
                } catch (Exception e) {
                    logger.error("补偿队列消费异常,休眠 1 秒后重试", e);
                    try {
                        Thread.sleep(1000L);
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        break;
                    }
                }
            }
        }
    });
    thread.start();
}

修复要点:

  1. try-catch 包裹整个循环体 :Redis 异常被捕获,记录日志后 sleep 1 秒重试,线程不死亡,Redis 恢复后自动恢复消费
  2. 队列为空时 sleep 1 秒:避免空转死循环,保护 Redis 连接池

对比项目中其他消费线程

项目中其他 Redis 队列消费线程都有类似的保护机制:

  • 统计报表消费线程:有 try-catch(Exception) + 队列空时 Thread.sleep(30s)
  • 转写任务消费线程:队列空时 Thread.sleep(60s)
  • 质检推送消费线程:有 try-catch(Exception) + 队列空时 break

旧代码是唯一遗漏了这两层防护的消费线程。

五、验证:服务重启后数据恢复

通过对比发包(服务重启)前后的数据恢复情况验证了根因判断:

  • 服务重启后,@PostConstruct 重新创建消费线程
  • Redis 已恢复,线程正常消费队列
  • 积压的 16804 条数据被逐步消费,推送成功
  • 之前失败的数据"回复好了"

这验证了根因判断的正确性:Redis 宕机 → 消费线程死亡 → 补偿队列停摆 → 重启后恢复。

六、关联问题:质检数据缺失

在排查录音上送问题的同时,业务方还反馈质检平台缺失部分录音的质检数据。经排查,这是一个独立问题,但触发时机与本次事故相同。

排查过程

  1. 确认 Redis 质检队列是否有数据:通过调用质检平台的接口确认,质检平台没有收到数据。检查 Redis 中的质检队列,发现队列为空------说明上游的语音转写服务没有往队列里推数据

  2. 排查转写服务 :去转写服务所在的服务器查询日志,发现 转写服务在服务器重启后没有启动

  3. 根因 :转写服务没有被纳入容器编排平台管理,而是以独立进程方式部署在虚拟机上。容器编排平台管理的服务在 Pod 异常或节点重启后会自动拉起,但未纳管的服务不会自动恢复。服务器重启后,转写服务没有启动,语音转写停止,质检队列没有数据写入,质检推送自然也就停了

  4. 解决:手动重启转写服务后,转写恢复,质检队列重新有数据写入,质检推送恢复正常

与录音上送问题的关联

两个问题虽然根因不同,但触发时机相同------都是服务器重启后暴露的:

问题 根因 触发条件 解决方式
录音未上送业务平台 补偿队列消费线程无异常防护,Redis 宕机后线程死亡 Redis 宕机 修复代码(try-catch + sleep)
质检数据缺失 转写服务未纳入容器编排,服务器重启后不自动启动 服务器重启 手动重启转写服务,建议纳入容器编排管理

七、完整因果链

复制代码
服务器重启 / Redis 宕机
  │
  ├─ 补偿队列消费线程
  │   └─ leftPop 抛出 RedisConnectionFailureException
  │       └─ 旧代码无 try-catch → 线程永久死亡
  │           └─ 补偿队列积压 16804 条(大 Key 排查时发现)
  │
  ├─ 拨号盘外呼无法走路径 A(补偿队列)
  │   └─ 只能走路径 B(定时批量扫描)
  │       └─ 路径 B 的工单ID 来自活动数据表 JOIN
  │           └─ 拨号盘外呼的活动数据ID=0,JOIN 不到
  │               └─ 工单ID 为空
  │                   └─ 业务平台返回"操作失败"
  │                       └─ 标记为同步失败,不再重试
  │
  ├─ 转写服务(未纳入容器编排)
  │   └─ 服务器重启后不自动启动
  │       └─ 语音转写停止
  │           └─ 质检队列无数据写入
  │               └─ 质检平台缺失数据
  │
  └─ 服务重启 / 手动重启转写服务
      ├─ @PostConstruct 重新创建消费线程 → 补偿队列恢复
      ├─ 转写服务恢复 → 质检队列恢复
      └─ 积压数据被消费,推送成功

八、涉及的服务

服务 角色 影响
三方接口调用服务 接收业务平台调用,存入 Redis 缓存 Redis 宕机时写入缓存失败
通话任务调用服务 定时补全数据 + 补偿队列消费推送 + 质检推送 核心受影响:消费线程死亡,推送停摆
转写服务 语音转文字,转写完成后推入质检队列 未纳入容器编排,服务器重启后不自动启动
Redis 缓存 + 补偿队列 + 质检队列 宕机是触发根因
上游业务平台 接收推送 活动过期时返回"操作失败"(独立原因)
质检平台 接收质检数据 转写服务停摆导致无数据

九、经验教训

1. Redis 队列消费线程必须具备两层防护

  • try-catch 包裹循环体:捕获异常后 sleep 重试,线程不死亡
  • 队列为空时 sleep:避免空转耗尽连接池

缺失任一层都会导致 Redis 宕机后消费线程永久死亡,且 @PostConstruct 不会重建,只能靠重启服务恢复。

2. 正确解读 Redis 内存指标

排查时容易被 RDM 界面的百分比误导,需要区分三个不同维度的"占比":

指标 含义 容易误读为
used_memory_dataset_perc (98.69%) 数据集占 Redis 已用内存 的比例 ❌ 服务器内存占用率
used_memory_peak_perc (88.90%) 当前内存占 历史峰值 的比例 ❌ 服务器内存占用率
used_memory / total_system_memory (2.8%) Redis 占 服务器总内存 的比例 ✅ 这才是服务器内存占用率

判断 Redis 内存是否健康,应该看 used_memory(绝对值)和 used_memory / total_system_memory(服务器占比),而不是 dataset_perc 或 peak_perc。

3. 补偿队列积压量是消费线程是否存活的直接指标

队列积压 16804 条是线程死亡的第一个直接证据。日常监控应关注补偿队列长度,积压超过阈值告警。

4. 服务重启能恢复但不是解决方案

重启恢复是因为 @PostConstruct 重新创建线程,但这只是临时手段。根本修复是让线程具备异常自恢复能力。

这里要特别说明: 重启服务虽然不是最优解决方案, 但确实最快解决方案. 在遇到外部组件/服务重启/异常导致当前项目执行出问题后, 短时间没办法定位问题时, 应该优先重启项目!!!

5. 兜底路径不一定真的能兜底

路径 B 设计为兜底,但拨号盘外呼的活动数据ID固定为 0,JOIN 不到工单ID,兜底实际无效。设计兜底路径时需要验证它在所有场景下都能拿到必要数据。

6. 所有服务必须纳入容器编排管理

转写服务未纳入容器编排管理,服务器重启后不自动启动,导致质检链路断裂。未纳管的服务是运维盲区------没有健康检查、没有自动恢复、没有统一监控。所有生产服务都应纳入容器编排平台管理,确保异常退出或节点重启后能自动拉起。

7. Redis maxmemory=0 是隐患

未设置内存上限意味着 Redis 不会主动保护自己,极端情况下可能吃满服务器内存导致 OOM。建议根据实际内存情况设置兜底值,并选择合适的淘汰策略。

8. 一次服务器重启可以暴露多个隐患

本次事故中,服务器重启同时暴露了两个问题:补偿队列消费线程无异常防护(代码层面)和转写服务未纳管(运维层面)。排查时不能只盯着一个问题,要排查同一触发条件下的所有下游链路。

相关推荐
2301_32241428045 小时前
基于SpringBoot+Vue的休闲零食超市管理系统的设计与实现59004-计算机毕设 原创(免费领源码+部署教程+开发环境)
java·vue.js·spring boot·redis·mysql·决策树·推荐算法
金銀銅鐵7 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
福建佰胜张工8 小时前
SMC 薄型气爪 MHF2-16D1 深度解析:平行开闭、低剖面结构在自动化装配中的选型与落地
java·服务器·安全·自动化
catoop8 小时前
API Key 设计与安全生成方案:结构拆解、数据库设计与 Java 实现
java·数据库·安全
Web极客码8 小时前
我用 Claude 分析 Redis 哨兵日志,实现跨机房数据不一致自动对齐
数据库·redis·php
system_error_x8 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
驭渊的小故事9 小时前
Spring AI Alibaba 实战:从maven依赖治理到声明式工具调用
java·人工智能·spring
Elastic 中国社区官方博客9 小时前
我们如何通过 70 多个拉取请求,将 OpenTelemetry Java agent 迁移到 invokedynamic
java·大数据·开发语言·elasticsearch·搜索引擎·全文检索·es
tyn1889 小时前
记录本地安装nacos服务的方法及异常
java·开发语言