记录一次批量推送的优化

281 阅读11分钟

最初目的 : 

批量推送1000w用户耗时十小时+ -> 对于多次网络IO的Redis操作集合 采用 redis module 进行优化

第一步 确认关键耗时模块 :

毕竟整个服务涉及到的部分很多, 包括TCP、Redis、网络IO、MongoDB、RabbitMQ、Kafka、线程、内存等, 都可能是耗时瓶颈, 所以在制定优化计划之前, 还是很有必要进行一下各个部分的耗时记录的

意料之外的异常 :

虽然测试出是某一块Redis的操作耗时过长, 但是测试结果非常不稳定, 即使采用完全相同的数据, 每次操作耗时也会出现 20倍 不止的差距, 已经超出网络波动和服务器波动的范围了, 推测程序或者中间件出现异常

针对异常采取的操作 :

重复测试, 确认所有发生波动的操作, 发现主要的波动来自Redis操作和HTTP请求

测试过程中发现耗时波动时会伴随报警信息, 此处以Redis为例

Timeout performing ZADD (5000ms) IP: xxx.xxx.xxx.xxx (qs:255)
IOCP: (Busy=0, Free=32767, Min=160, Max=32767)
WORKER: (Busy=232, Free=32535, Min=160, Max=32767)
POOL: (Threads=238, QueuedItems=3347, CompletedItems=21807)

异常分析 :

Redis 客户端 qs:255 (队列挤压) , WORKER线程池繁忙 , IOCP线程(负责网络IO的线程)空闲 , Redis负载无异常 , 网络IO无异常 

合理推测出Redis客户端由于整个服务线程资源被抢占而线程资源饥饿, 排队超时, HTTP请求超时也是一样的原因

待解决的问题 :

优化RedisIO耗时 转变成 排查线程资源泄露问题

第二步 确认线程资源泄露模块 :

通过线程查看, 发现该服务的线程数竟然会达到1000~2000

由于该服务推送的对象是全体东财用户 并发量很高, 因此程序中其实大量使用了 Parallel 以及 Task.Run, 所以合理推测是不是因为Parallel没有管控住线程数从而导致的线程激增, 资源泄露? 

针对上述猜想采取的操作 :

设置Parallel.Option降低最大线程数 16->4

测试结果, 资源泄露问题没有解决

针对上述异常采取的操作 :

对于进程的转储文件进行分析, 去看看多出来的一千多个线程在干什么

.load C:\Users\Administrator.dotnet\sos\sos.dll 
! threads 
~*e !clrstack 

发现该转储文件的1500个线程中, 存在大量的线程处于 lib_kafka_error 的堆栈调用中

后面发现其实当时调用~*e !clrstack其实只返回给我了300个托管线程, 不是导致线程激增的关键问题算是偶然发现的小问题, 不过当时我不知道, 我以为问题就是这里, 总之先去解决了kafka导致的线程问题

通过调查发现lib_kafka_error这种操作往往出现在KafkaProducer当中

而这个服务中的KafkaProducer由两个部分组成, 一个是所有服务都在用的资源上报和接口日志调用记录的KafkaProducer, 一个是该服务自己的同步KafkaProducer

保险起见我先注释掉了所有服务通用的资源上报和接口调用日志记录的KafkaProducer, 问题没有解决

定位问题 :

问题出现在该服务的同步数据的Kafka生产者

针对上述问题采取的操作 :

竟然出现了大量的lib_kafka_error, 所以我想看看什么对象导致的这样的异常产生, 所以进一步对dmp文件进行了 !dumpheap -stat 查看堆上对象的统计, 发现和lib_kafka_error对象同时存在的还有KafkaClient, 整个程序在同步的时候创建几十个Kafka链接, 所以导致了很多线程的调用堆栈中存在lib_kafka_error, 这里说明一下lib_kafka_error并不一种异常, 而是存放可能出现的异常的对象

​待解决的问题 :

排查线程资源泄露问题 转变成 为什么创建了那么多Kafka链接

第三步 为什么创建了那么多Kafka链接:

很简单, 创建实例的时候使用了一个假的单例模式, 高并发多次创建, 修复测试, 不再出现多线lib_kafka_error相关的线程

第四步 重返第一步 :

发现修复完Kafka的问题, 耗时依然波动, Redis和HTTP请求依然超时, 线程资源依然泄露 ? 

第五步 重返第二步 :

重新转储线程文件, 发现了这次转储的线程文件虽然存在上前的线程, 但仔细观察发现了里面相当大一部分是没有办法查看的非托管线程

再不断的重启程序中, 发现线程的激增分两种情况, 一种是瞬间增加到2000, 一种是慢慢增加到两千

经过多次的启动和转储, 获得了另一种转储文件, 终于找到了大量的新出现的异常托管线程 NewConnection()

结合业务代码发现, 此处是RabbitMQ链接的创建

先是Kafka异常再是RabbitMQ异常, 因为都是消息队列, 怀疑是一样的原因导致的, 按照Kafka修复的方法去查看了一下, 问题并不是这样的​

​​待解决的问题 :

排查线程资源泄露问题 转变成 为什么程序创建那么多RabbitMQ链接又不复用

第六步 RabbitMQ问题修复[本次优化最耗时的地方] :

为什么会频繁调用 NewConnection() ?

因为我们链接池中一直没有创建好的 ?

为什么创建好的链接没有放到链接池 ?

通过调试我发现, 那是因为从来没有任何一个线程创建好了链接

所有线程都阻塞在了RabbitMQ驱动中的CreateConnection()

这也就是整个服务线程激增以及其他所有相关问题出现的原因

问题分析

结合高并发的场景, 以及该类加载的时候耗时较长

推测 此时系统的线程被创建增加, 等到类加载完成的时候已经创建的大量的线程等待建立NewConnection

因此导致大量线程进入NewConnection方法去创建新的链接

( 我们在此处并没有做限制, 因为一般情况下不会有两三百个线程去获取RabbitMQ链接的 )

因此导致大量线程去发起了 CreateConnection , 也是因此导致 CreateConnection 缺少回调线程一直阻塞等待, 由于NewConnection 不会被创建因此链接池中永远没有链接, 然而任务一直在增加, 线程一直在增加但是也都去创建 NewConnection, 导致 CreateConnection 线程资源饥饿, NewConnection 自己死锁了自己

​针对上述猜想采取的操作 :

抽取出简单的模型去创建线程, 在创建过程中, 类的实例我使用Thread.Sleep卡死类的加载, 等线程达到400时  一起创建NewConnection, 完美复刻了RabbitMQ的异常情况

首先在大并发的情况下创建了数百连接, 之后由于TCP超时, 不断有连接被踢掉和重新创建, 最终的结果就是连接维持在十个左右, 但是不断的更新端口号 (频繁创建和销毁)

并且NewConnection 由于缺少回调线程导致不异常也不结束, 这也就是导致Redis和HTTP请求超时的最终原因, 因为没有空闲的线程去发送Redis请求

进一步验证, 逐渐降低任务数量, 线程池的线程会继续增加, 在并发量没有那么高的情况下, 线程会在一直增加直到增加到有足够的回调线程之后, RabbitMQ连接会恢复到正常水平

改进 

预先加载RabbitMQ驱动操作类, 在进入高并发场景之前完成类的加载

因为RabbitMQ不是瓶颈, 完全不影响服务性能, 因此为了降低并发数, 不采用Task.Run, 而是更换成单个线程去执行

到此, 线程资源泄露问题解决

第七步 Reids优化

结合年前进行的RedisMoudle以及RedisLua的调研, 发现在多次RedisIO的情境下使用RedisModule以及RedisLua进行优化效果异常显著, 明显降低整体的耗时, 提升性能11倍, 由于RedisLUA部署和调试更为方便, 并且提升性能的原理和RedisModule无差异, 所以后续的优化操作采用了RedisLUA进行实操

Redis LUA脚本介绍

-- Lua 脚本: 设置键值并返回旧值
local key = KEYS[1]
local value = ARGV[1]
local old_value = redis.call("GET", key)
redis.call("SET", key, value)
return old_value

可以将原本的GET,SET两步操作结合成一次, 降低网络IO耗时


之后将原本的Redis操作数据进行整合

设置个人离线
...秘...
更新包含免扰的未读消息数和不包含免扰的未读消息数

以上不包含第二步一共14次Redis请求, 14次网络IO 通过LUA脚本结合成一次

之所以不包含第二步是因为使用RedisLUA在集群中无法支持跨槽点的操作, 因为涉及到了Redis的跨界点通信, 第二步是以发送者角度进行的操作, 无法列入同一个SLOT, 因此对上述其他操作进行合并, 并且通过 {} 强制 hash的办法 设计了用户分片 前缀_{ID}_后缀

​最终生成脚本文件

local configAndLastMessageKey = ARGV[1] 
...秘...
end

就是用LUA脚本的语言对于上述的原本服务端的Redis逻辑转移到了Redis服务器

如何让我男朋友也看永雏塔菲?_1_C₂H₄乙烯_来自小红书网页版.jpg

优化耗时 : 1000w条数据 12h15min -> 1h58min

第八步 优化后续

a. 可以通过RedisMoudle进一步压榨Redis服务器的性能, 但是Redis模块的维护成本较高, 并且模块崩溃可能导致Redis进程崩溃, 风险较大, 并且我们的优化主要目的是Redis的网络IO, 因此不考虑

b. 进一步优化指令从结合14个操作变成结合n个用户的14*n个操作, 但是需要改动的业务代码较大, 需要上下游服务配合改造, 并且一次过于耗时的Redis操作不是一个合理的设计因为可能导致其他的Redis操作阻塞, 14个指令降低IO耗时已经表现不错了, 因此暂时不考虑, 可以作为后续的优化方向

第九步 优化小结

这次优化过程中,最棘手的部分就是每次运行程序时,结果都可能不同。整个优化过程涉及到多个模块,包括 TCP、Redis、网络IO、MongoDB、RabbitMQ、Kafka、线程、内存 等,每次运行的结果都有差异,尤其是在高并发、高流量的环境下,优化的难度大大增加。

其中一个非常麻烦的地方是线程资源泄露的问题。导致线程资源泄露的原因并不唯一,经过多次修改和调整,虽然解决了部分问题,但由于没有一个明确的根本原因,导致修改逻辑后并不能直接达到预期的效果。每次优化过程中,但往往会发现新的问题

并且测试环境也会对优化结果产生影响。特别是在开盘高峰时,流量和负载急剧变化,导致每次运行的性能测试结果可能存在差异,这进一步加大了优化的难度。甚至在同一个测试条件下,由于外部环境因素的变化,优化效果可能出现波动,给我们带来了不少困扰。

总的来说,这次优化工作最大的挑战就是面对多个复杂系统的交互,每个系统的状态和负载都可能影响优化结果。因此,在修改部分逻辑后,不能马上看到预期效果,必须反复测试,调整,才能逐渐找到合适的平衡点。

 过程很糟糕, 但是结果很好

第十步 RedisLUA脚本在部门内部的改造计划

a. 针对各自业务内的 依赖上一次Redis操作结果来操作下一次Redis的操作 都可以考虑采用RedisLUA脚本进行优化 [推荐涉及到3次以上RedisIO最佳, 因为RedisLUA脚本也有维护成本]

b. 原本的for循环中的Redis操作可以顺便在这次改造中采用管道进行优化

c. 针对一些通用的Redis操作 比如 GET 赋值 之后 SET 可以编写通用的脚本