一、诡异的 SQL 现象:2ms 的 SQL 竟占 27% 耗时
上周在排查 WebSocket 服务性能时,发现一条 "平平无奇" 的 SQL 堪称数据库杀手:
UPDATE client SET last_time=? WHERE id=?
监控数据让我惊掉下巴:
- 平均执行耗时仅 1.95ms
- 却占据了数据库总耗时的 27%
- 每秒执行次数高达 721 次!
高峰期 10 点到 22 点,峰值甚至达到 750 次 / 秒。这意味着什么?假设数据库连接池最大连接数 100,这条 SQL 就可能占用 7 个连接,直接导致其他业务卡顿。
二、追查真凶:Netty里的 "定时炸弹"
第一反应是 WebSocket 心跳机制在搞鬼,查看 Netty ChannelPipeline 配置:
查看该心跳处理器,心跳处理器中没有做任何DB交互可以,排除心跳处理器
如果是在非心跳处理器的场景,仍然这么频繁的更新状态,那么最大可能有2个场景: 1、在Netty写场景的时候,设置了出站处理器,在每次出站的时候更新了设备最后在线时间
2、在Netty读场景的时候,设置了入站处理器,在每次收到读事件都去更新设备最后在线时间
从场景上分析,场景2的可能性比较大。
分析Netty ChannelPipeline,发现除了MyWebSocketFrameHandler,其他的处理器都是Netty原生
而MyWebSocketFrameHandler又是一个入站处理器,所以基本定位是在MyWebSocketFrameHandler内部更新了设备的最后在线时间
在MyWebSocketFrameHandler内部将心跳帧去做了设备更新操作
结论:
1、在每一次ws的文本帧消息,打印结果推送的时候,都会去更新状态
2、即使是心跳帧,也会在MyWebSocketFrameHandler更新设备状态
真相是:每次收到 WebSocket 消息,无论是否是心跳帧,都会执行一次 DB 更新
三、确定优化方案
3.1 优化思路
从代码上看,在业务量比较小的时候,这种方式去更新db是很难暴露问题的,因为是根据id更新单条SQL执行只要2ms。当业务量增加后,设备变多,每次心条都更新设备在线时间,对db的压力就逐渐增加了。
一般来说项目通常会达到性能的瓶颈。这些瓶颈总结起来,大概有两种,读瓶颈,和写瓶颈
而当前场景是写瓶颈,写瓶颈的处理方式通用的例如异步、打散、合并
高频、多次、散点的场景 可以考虑使用请求折叠(合并)
请求折叠思路很常见,例如:
TCP网络协议-Nagle算法
Kafka中也见过类似的优化-在发送消息的时候并不是一条条的发送的,而是会把多条消息合并成一个批次Batch 进行处理发送
请求折叠主要是2个维度合并:
- 大小
- 时间
Kafka中这2个配置分别对应batch.size、linger.ms,达到任意一个条件都开始执行
3.2 方案调研
一、redis
更新数据存redis、定期任务合并刷库
xxl-job任务为秒级,以任务间隔设置为5s为例
按峰值750TPS,每次请求记录id和时间,单json大小约等于50字符,5s可产生3750个json对象。redis key所占大小约为200kb左右,所以可以按照每个品牌一个key隔离。
优点:不需要额外引入新的依赖
缺点:只能从时间维度控制频率,随着业务增长,每个批次处理的数据量不可控,导致job执行间隔不可控
二、Hystrix
Hystrix自带请求折叠组件---「hystrix Request Collapsing」
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
<version>${spring-cloud-starter-netflix-hystrix.verson}</version>
</dependency>
优点:
- 支持从时间、大小两个维度控制频率,既能保证时间可控,又能保证批次大小可控
- 编码简单,只需要提供一个批量方法、加一个注解
- Spring Netflix 组件
缺点:
- 需要额外引入Spring-Hystrix依赖
三、BufferTrigger
BufferTrigger-快手开源的流量聚合工具,在快手的点赞、电商场景有真实场景实战落地经验
优点:
- 有推背模式,如果处理压力过大,可以反馈给上游,用拒绝策略来实现投递的阻塞(此优点对当前场景无用)
缺点:
-
只有单线程模式
-
不能分组批量聚合
3.3 结论
选型 | 推荐指数 |
---|---|
redis | 🌟🌟🌟🌟 |
hystrix-request-collapsing | 🌟🌟🌟🌟🌟 |
BufferTrigger | 🌟🌟🌟 |
三、方案落地
单个执行的方法:
实际执行的方法:
ps:现状的峰值是750TPS,MSG服务一共7个节点,每个节点处理的TPS约等于100,所以此处设置延迟时间500ms,最大的合并请求数量为50。这样单节点处理2TPS,DB的峰值TPS在14左右
理论上每秒TPS将从750 -> 14 ,优化近50倍
四、压测报告
压测案例一
200个线程,循环次数6,共打出1200次请求
报告如下:
折叠成68个批量请求,平均每个批次折叠17.6个请求
压测案例二
设置200线程数,1s全部启动,无限循环,执行5s
一共请求10847次(2000TPS)
折叠请求数:550(实际到数据库只有110TPS),平均每个批次折叠19.7个请求
五、上线监控观测
上线前:时间筛选2024年11月22日 14:35:00 - 2024年11月22日 15:35:00
该SQL占整个数据库25.94%的耗时
上线后:对比2024年12月6日 14:35:00 - 2024年12月6日 15:35:00(同时间段)
该SQL占整个数据库0.31%的耗时
结论:对比上线后近7天的数据,优化后可以一条占据全库27%耗时的 SQL 降至0.31%
执行次数对比:
上线前:
上线后:
六、hystrix-collapser 源码分析
6.1 定时任务实现原理
找到请求合并类注解的切面
com.netflix.hystrix.contrib.javanica.aop.aspectj.HystrixCommandAspect#methodsAnnotatedWithHystrixCommand
继续往下跟踪代码:
com.netflix.hystrix.HystrixCollapser#toObservable(rx.Scheduler)
可以看到在这里进行了submit:
com.netflix.hystrix.collapser.RequestCollapser#submitRequest
找到了添加定时任务的逻辑:
com.netflix.hystrix.util.HystrixTimer#addTimerListener
在addTimerListener方法中,调用ScheduledExecutor的scheduleAtFixedRate,开始定时任务
6.2 批次大小的判断时机
com.netflix.hystrix.collapser.RequestBatch#offer
如果大于等于批次大小,则直接返回null,开始合并操作
6.3 为什么会批次超限
设置最大批次的时候设置的是20/批,但是压测时发现有少部分批次是超过20/批的
找到hystrix 切面 add存队列的方法:com.netflix.hystrix.collapser.RequestBatch#offer
offer用的是读锁,执行批次用的是写锁。
这说明加入队列和执行批次是互斥的,但是加入队列这个动作本身是不互斥的
此处应该是为了性能,接受少部分超限的情况
即使设置了maxBatchSize=20,并不意味着最大就是20/批
七、应用场景
请求折叠通过将多个相似的请求合并成一个来减少系统负载、降低延迟和提高吞吐量。以下是一些使用请求折叠的应用场景:
-
缓存穿透:
- 在高并发环境中,多个线程同时访问同一个缓存未命中的数据项时,可以合并这些请求,只对数据库进行一次查询,从而减轻数据库压力。
-
批处理 操作:
- 将多个独立的数据库或网络调用合并为一个批量操作。例如,JDBC的批量插入、更新操作可以减少数据库连接次数,提高性能。
-
微服务通信:
- 在微服务架构中,可以将多个对同一服务的HTTP请求合并为一个,以减少网络开销和响应时间。GraphQL等协议提供了类似的能力。
-
异步消息处理:
- 消息队列在处理大量消息时,可以将多条消息合并为一个批次进行处理,从而提高消费效率。
-
流数据处理:
- 在实时流处理系统中,将短时间内到达的多条事件合并成一个批次处理,以提高吞吐量。
-
远程调用优化:
- 如果多个RPC调用涉及相同的数据节点,可以合并请求,减少跨网络调用的次数和相关的序列化/反序列化开销。
八、总结与反思
这次优化让我深刻体会到:
- 高频小操作的威力:单个 2ms 的操作,高频次下也能拖垮整个系统
- 请求合并的价值:Nagle 算法、Kafka Batch 等经典设计值得反复学习
- 读写锁的妙用:Hystrix 通过读写锁实现了高性能的请求折叠
如果你也遇到类似的高频操作问题,不妨试试请求折叠术,说不定会有惊喜。