请求折叠的魔术:数据库总耗时降低了25%?

0 阅读8分钟

一、诡异的 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调用涉及相同的数据节点,可以合并请求,减少跨网络调用的次数和相关的序列化/反序列化开销。

八、总结与反思

这次优化让我深刻体会到:

  1. 高频小操作的威力:单个 2ms 的操作,高频次下也能拖垮整个系统
  2. 请求合并的价值:Nagle 算法、Kafka Batch 等经典设计值得反复学习
  3. 读写锁的妙用:Hystrix 通过读写锁实现了高性能的请求折叠

如果你也遇到类似的高频操作问题,不妨试试请求折叠术,说不定会有惊喜。