背景

2017 年前后,我在大连 R 公司负责 R 平台协议解析层的开发和维护。R 平台是一套车联网平台,部署在 H 云上,用来接收和处理车载设备实时上报的数据。

协议解析层有十五个以上的服务,每个服务至少两个节点,负责解析车载设备上报的自定义 TCP 协议,网络通信与连接处理底层由 Netty 实现。平台共有 300 多台虚拟机,单个服务的规格通常不会拉很高,G4 所在的虚拟机配置是 2 CPU、4 GB 内存。

其中 G4 是协议解析层数据量最大的服务,负责解析行车数据,单条消息包含约两百个字段,涵盖车速、转速、门窗锁状态、发动机参数、各类液体温度、油量等实时车况信息。正常 QPS 约 700 条/秒,峰值超过 1000 条/秒,四个节点加起来总 QPS 在 5000 左右。

解析完成后,G4 把数据写入 Redis(实时数据)和 MySQL(行车历史数据,水平分片成 1024 个库),另外还有一些平台内部的 API 调用。MySQL 访问层是在 JDBI 基础上封装了一层。

协议解析层的数据来自上游接入层,接入层向协议解析层推送数据,同时用 RabbitMQ 做消息队列缓冲。

装车增长与早期告警

R 平台接入的设备数量持续增长,每个月有几千台新设备安装到实车上。随着在线行驶车辆增多,G4 的数据量也跟着涨。

大规模故障之前,G4 就开始在早高峰和晚高峰偶发内存告警,高峰过去后十几分钟会自行恢复。当时不清楚具体原因,尝试过把业务线程数从默认值增加到 16,改完之后等服务低峰期重启,观察下一次高峰是否有改善。效果不明确,可能有一定缓解,也可能影响有限,之后就转向其他方向排查,线程数就维持在 16 没有再改。

大规模 OOM

装车量继续增加,某天 G4 出现了大规模 OOM。现象是四个节点滚动离线:一个节点 OOM 重启,剩余节点压力上涨,接着下一个离线,重启后很快再 OOM,如此循环。

节点重启之后撑不了太久,因为上游 RabbitMQ 里已经积压了大量数据,积压时间估计有一两个小时。一个节点恢复,积压数据涌入,Netty 堆外内存里挤满了等待处理的业务数据,内存很快再次撑满。一个节点离线,剩余三个节点分摊的流量更大,内存涨得更快,形成连锁反应。节点之间的 OOM 间隔只有几分钟。

从 Netty 堆外内存的视角看,核心是入口流量和处理能力失衡:当单位时间内进入 G4 的消息量持续大于业务线程实际处理并完成释放的消息量时,待处理数据就会形成积压。积压会先体现在连接读缓冲、解码后的 ByteBuf、以及进入业务队列但尚未完成后续写库流程的数据对象上。由于这些缓冲和对象关联了 Netty 的 direct memory,且释放依赖处理流程继续推进,消费被 MySQL 写入延迟拖慢后,释放速度跟不上分配速度,堆外内存占用就会持续抬升。

在“节点越少,单节点分到的流量越大”的连锁场景里,这个不平衡会被进一步放大:某个节点 OOM 离线后,其流量被其余节点分摊,等价于其余节点的入口速率再次提高,导致 direct memory 增长更快,于是出现几分钟内连续 OOM 的滚动离线。

关于这个过程在 Netty 层的细节,可参考 Netty 堆外内存 中“消费速率不足时的堆外内存积压”一节。

应急扩容

查看应用日志和 Linux 系统日志,确认是进程内存占用过大被内核 OOM Killer 杀掉。

当时没有时间深入调查根因,决定先扩容保住业务。运维同事批量创建虚拟机,我通过自动运维系统批量部署 G4 组件,整个扩容过程是小时级的。一边扩容一边看监控,一路扩到 10 个节点,上游积压的数据逐渐消费完,业务恢复稳定。

BTrace

10 个节点稳下来之后,有时间做深入调查了。

调查方向是:G4 主要往 Redis 和 MySQL 写数据,重点看这两侧的写入延迟,哪一侧延迟更大?延迟高的话,处理线程会被阻塞,Netty 里待处理的数据越积越多,最终耗尽内存。当时预感是 MySQL 慢,但需要数据支撑。

用 BTrace 在线织入,不重启服务,先在 G4 外层的批处理函数上打点,看消费每一批数据的总耗时;再向内层收窄,分别统计写 Redis 和写 MySQL 各自花费的时间。结论:写 MySQL 的耗时占整体处理时间的比例最大,量级是几十毫秒,写 Redis 则是几毫秒量级。BTrace 相关的内容记录在 BTrace。

调查到这里,理论上还有一个方向可以继续深挖:MySQL 连接池参数是否配置合理,或者 MySQL 实例本身的容量是否见顶。当时对 MySQL 侧的监控数据没有太深的印象,或许库侧容量还够用。但我们最终没有在 MySQL 的参数和配置方向上继续投入,而是选择换库。背后的考量是:行车历史数据是典型的时序场景——追加写、以时间戳为主键、读侧按时间范围查询,时序数据库在这类写入模式下有结构性优势。即使调大连接池或优化分片,关系型数据库在高频宽表 insert 场景下的写入效率,和时序数据库相比仍然有量级差距。领导此前已经调研过 InfluxDB,这个判断和 BTrace 拿到的数据是一致的,于是决定直接换库。

宽表高频写入的问题。

迁移到 InfluxDB

我们决定迁移到 InfluxDB 1.x。

2017 年 7 月前后,我开始阅读 InfluxDB 官方文档,在测试环境搭建实例、做写入性能验证,和那段时间的笔记 InfluxDB 是同一时期的记录。

InfluxDB 开源版没有官方集群,我们在应用层做水平拆分:部署四个 InfluxDB 实例,G4 按设备 ID 哈希后路由到对应的实例写入。

迁移只针对 G4 写的那组行车历史数据,不涉及平台其他 MySQL。迁移策略是双写:先让 G4 同时写 MySQL 和 InfluxDB,下游服务逐步把读库切到 InfluxDB。下游有两个团队,涉及大约两到五个读库服务,包括运维平台的行车数据展示和大数据部门的行车轨迹计算(大数据部门有定时任务从在线库拉数据存到 Hadoop,在线库只保留一段时间)。双写持续了大约一两个月。等所有下游都切完读,我们升级 G4 版本禁用写 MySQL 的逻辑,又过渡了一段时间,最后完全删除写 MySQL 的代码,回收那组 MySQL 资源。

结果

换库完成之后,G4 写行车历史的耗时从几十毫秒量级降到几毫秒量级。OOM 问题消失,G4 也从 10 个节点逐步缩回 4 个节点。

回头看

早期偶发告警的阶段,如果及时做容量规划或扩容,可能不会走到四个节点循环 OOM 的局面。当时加线程到 16 是一个探索尝试,效果不明确,换 InfluxDB 之后线程数也没有再调整。后来用 vmstat 观察过,G4 每秒上下文切换超过 4000 次,这和 2C 机器上跑 16 个业务线程有关系,但换了 InfluxDB 之后 OOM 问题已经解决,降线程的事就搁置了,一直没有验证。

平台后来引入了 Kafka,当时也讨论过把 G4 写 InfluxDB 的逻辑挪到 Kafka 后面,让协议解析层只发消息、由独立的消费者负责写库,这样可以把写库压力从解析进程里剥离出去。但因为下游读库服务的依赖没有完全理清,这个方案没有大规模落地。

据同事说,协议解析层最早曾用 Spring 实现,遇到过一些问题,又考虑到这一层需要处理的是 TCP 上的自定义协议,并发高、不是 RESTful API,最终换成了 Netty。我入职的时候 Netty 已经在用了,这段历史是听同事说的。

技术栈

  • 运行时:JDK 8,协议解析使用 Netty,无 Spring
  • 数据库访问层:JDBI + 自研封装
  • 时序库:InfluxDB 1.x(最初部署约为 1.3,后升级,最高约 1.7)
  • 消息队列:RabbitMQ(接入层向协议解析层推送数据)

相关阅读