一、事故背景
某呼叫中心平台通过外呼系统进行电话营销,通话结束后需要将录音文件和通话记录推送到上游业务平台。业务方反馈:部分拨号盘外呼的通话记录,录音文件在软交换侧已生成,但未成功上送到上游业务平台,导致业务平台侧无法关联到录音文件,影响后续的业务回执和质检流程。
经初步统计,受影响的通话记录在本地数据库中状态为"同步失败"(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。
旧代码的两个致命缺陷:
- 没有 try-catch :Redis 异常直接跳出
while(true),线程永久死亡 。@PostConstruct不会重新执行,补偿队列消费永久停止 - 没有 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();
}
修复要点:
- try-catch 包裹整个循环体 :Redis 异常被捕获,记录日志后 sleep 1 秒重试,线程不死亡,Redis 恢复后自动恢复消费
- 队列为空时 sleep 1 秒:避免空转死循环,保护 Redis 连接池
对比项目中其他消费线程
项目中其他 Redis 队列消费线程都有类似的保护机制:
- 统计报表消费线程:有
try-catch(Exception)+ 队列空时Thread.sleep(30s) - 转写任务消费线程:队列空时
Thread.sleep(60s) - 质检推送消费线程:有
try-catch(Exception)+ 队列空时break
旧代码是唯一遗漏了这两层防护的消费线程。
五、验证:服务重启后数据恢复
通过对比发包(服务重启)前后的数据恢复情况验证了根因判断:
- 服务重启后,
@PostConstruct重新创建消费线程 - Redis 已恢复,线程正常消费队列
- 积压的 16804 条数据被逐步消费,推送成功
- 之前失败的数据"回复好了"
这验证了根因判断的正确性:Redis 宕机 → 消费线程死亡 → 补偿队列停摆 → 重启后恢复。
六、关联问题:质检数据缺失
在排查录音上送问题的同时,业务方还反馈质检平台缺失部分录音的质检数据。经排查,这是一个独立问题,但触发时机与本次事故相同。
排查过程
-
确认 Redis 质检队列是否有数据:通过调用质检平台的接口确认,质检平台没有收到数据。检查 Redis 中的质检队列,发现队列为空------说明上游的语音转写服务没有往队列里推数据
-
排查转写服务 :去转写服务所在的服务器查询日志,发现 转写服务在服务器重启后没有启动
-
根因 :转写服务没有被纳入容器编排平台管理,而是以独立进程方式部署在虚拟机上。容器编排平台管理的服务在 Pod 异常或节点重启后会自动拉起,但未纳管的服务不会自动恢复。服务器重启后,转写服务没有启动,语音转写停止,质检队列没有数据写入,质检推送自然也就停了
-
解决:手动重启转写服务后,转写恢复,质检队列重新有数据写入,质检推送恢复正常
与录音上送问题的关联
两个问题虽然根因不同,但触发时机相同------都是服务器重启后暴露的:
| 问题 | 根因 | 触发条件 | 解决方式 |
|---|---|---|---|
| 录音未上送业务平台 | 补偿队列消费线程无异常防护,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. 一次服务器重启可以暴露多个隐患
本次事故中,服务器重启同时暴露了两个问题:补偿队列消费线程无异常防护(代码层面)和转写服务未纳管(运维层面)。排查时不能只盯着一个问题,要排查同一触发条件下的所有下游链路。