高并发写利器-组提交,我的Spring组件实战

高并发写优化理论

对于高并发的读QPS优化手段较多,最经济简单的方式是上缓存。但是对于高并发写TPS该如何提升?业界常用的有分库分表、异步写入等技术手段。但是分库分表对于业务的改造十分巨大,涉及迁移数据的麻烦工作,不会作为常用的优化手段。异步写入到时经常在实际工作中使用,但是也不适合所有场景,特别对于带有事务的写入请求,带事务的写入请求通常是需要同步告知用户处理结果,所以不适用异步处理。

我们都知道批处理会比单条处理快很多,只需要发起一次网络请求,在网络层面节省了N次TCP连接获取和发送数据的步骤。实际我测试过,通过shark抓包,发现建立一条TCP连接可能需要耗费10ms~50ms左右。如果是跨洲际的TCP连接更久,可能耗费几百毫秒。单是节省的多次TCP连接就能节省不少时间,其次还有程序代码的循环执行时间。所以将多个写请求聚合成一个合适大小的批量写请求,一次性将数据发送给服务器进行批量写入是最高效的。

MySQL的组提交原理

在MySQL层面,为了保证事务的可靠性和数据同步给备节点、从节点的可靠性。通常会开启双一设置。在双一设置开启后,就会在事务提交前将redo log、binlog落盘,事务才返回成功,这就是WAL机制。

sync_binlog=1

innodb_flush_log_at_trx_commit=1

我们知道由于WAL机制,写入请求在修改了数据页后不会立即刷回磁盘,而是通过记录rodo log和binlog保证事务的持久性和同步给从节点。写rodo log和binlog就是顺序写入的,涉及磁盘的顺序写机制。磁盘顺序写会比随机写快很多。MySQL为了进一步提升多个事务在高并发下写入binlog的性能,采用了"组提交"的概念。顾名思义就是将多个事务在单位时间内聚集起来,一起写入磁盘,就变成了多事务的批量顺序写入,性能高很多。

这里简单介绍组提交。首先MySQL有2个参数控制组提交的等待时间和组大小。

binlog_group_commit_sync_delay=N:在等待N μs后,开始事务刷盘(图中Sync binlog)

binlog_group_commit_sync_no_delay_count=N:如果队列中的事务数达到N个,就忽视binlog_group_commit_sync_delay的设置,直接开始刷盘

解释下这张图。首先在第一步就已经将redo log刷到磁盘了,接下来就是将多个事务聚合在一个组调用write函数写入OS的缓冲。第一个到达的事务就会开启一个新组,等待N个事务到达或者等待N微秒之后主动提交。假设事务T1到达并开启新组1,等待T2来到加入组1,等待时间满后T1主动调用write函数将T1、T2事务都写入OS缓冲。此时T1、T2组成的组1进入第二个阶段,准备调用flush函数将缓冲区的数据刷入磁盘。组1在第二阶段继续等待新事务加入,此时有新组到达就会将组2和组1合并新组,再调用flush函数将组1、组2数据刷入磁盘。整个过程是批量+顺序写入磁盘,是很高效的。

我的组提交Spring组件

我把这个组提交管理器的组件放到我GitHub上了,大家觉得不错的请Star,或觉得有优化空间的请提出mr,有错误的请斧正。

GitHub-组提交管理器

我们基于以上的理论分析,可以得出如果我们在高并发写入的时候能够模仿MySQL的组提交,实现一个主动等待和被动唤醒提交的组提交机制,将多个写入请求合成一个请求发送给MySQL就能提高写入性能。

总结MySQL的组提交机制原理:

  • 第一个到达的线程开启新组作为本组Leader领导本组的数据提交
  • Leader等待指定X毫秒时间,时间到后主动发起提交
  • 第K个线程到达,若发现本组负载满了唤醒Leader进行本组提交
  • 组与组之间互不阻塞,单位时间内可能有多个组并发提交

基于以上原理,我设计了两个类:GroupManager组管理器、GroupCommit组提交对象。GroupManager负责接收外部线程提交的数据,然后放到当前组里。并且实现整个组提交的流程。GroupCommit是一个组的具象化对象,提供一个组的入队,提交数据,挂起等待,唤醒Leader等基础方法,给GroupManager调用以实现组提交机制。

为了避免高并发时多线程竞相进入组内,导致组错乱,使用了两把锁解决。大部分线程都会被挡在第一关,每次只会放一个线程进到临界区尝试入组。入组之前要先获得当前组的锁,为什么要第二把锁?因为Leader会主动醒来提交本组的数据队列,所以提交时要确保所有资源都是排他的,需要组内锁来保证。入组的线程抢到组内锁之后就代表可以安全入组,此时有三种情况:

  • 如果此时入组前发现组已经满了就开一个新组自己当Leader并唤醒当前组的Leader让它赶快提交
  • 如果入组后发现组满了,唤醒当前组Leader让它赶快提交,自己则挂起等待提交后唤醒
  • 入组后发现还未满,挂起自己等待唤醒

线程在获取到组内锁后都会立即释放GroupManager的锁,目的是让后续线程如果发现当前组满了,就立即开新组提交,提高效率。

系统架构

因为我们工作中大多数使用的是Tomcat容器,目前Tomcat的IO处理模型是Reactor+线程池的模式。

在整个系统架构层面,组提交影响性能的有两个参数:组大小和等待时间。组大小就是在组内挂起等待的线程数,等待时间是Leader主动等待的毫秒数。组大小直接影响到剩余可工作的线程数,Tomcat线程数量默认200,通常我们根据业务场景和硬件资源调整,线程数量也就几百左右。如果组大小太大同时等待时间太久!!直接把Tomcat所有线程都挂起了这时服务器就假死了,所以对组大小的设置建议通过压测来确定,按照下面的压测经验一般建议设置为Tomcat线程数量的1/4~1/2。这样最大1/2能确保还有一半线程可以服务其它请求。

等待时间,因为这个参数会导致接口RT上升,建议设置在5ms~20ms之间。我们生产MySQL的组提交等待时间设置500微秒,是很短的。我经过反复压测和调参发现,纯MySQL插入操作,等待时间5ms左右就合适了。

总结起来,组大小和等待时间需要根据业务类型和Tomcat线程数量和CPU数量,经过测试来决定一个合适的参数,没有通用的方法论能决定。

在整个系统架构层面,负载均衡器和服务器Pod,Tomcat线程池和多个组提交的关系。

压测报告

环境介绍

  • Mac OS M2 10核16G,SSD
  • MySQL 8.0
  • JDK8u221
  • SpringBoot,Tomcat线程池400
  • Druid数据库连接池 40连接数
  • Jmeter 5.3,700线程并发,循环1000,共70万请求
  • JVM参数设置

-XX:-ClassUnloadingWithConcurrentMark -Xms4g -Xmx4g -Xmn3g -XX:G1HeapRegionSize=4m -XX:InitiatingHeapOccupancyPercent=30 -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=268435456 -XX:MetaspaceSize=268435456 -XX:ParallelGCThreads=10 -XX:+ParallelRefProcEnabled -XX:-ReduceInitialCardMarks -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC

MySQL没有经过调优都是默认的参数。MySQL和应用服务还有Jmeter都是在Mac上运行的。对比两种测试用例:1.使用组提交组件 2.单条数据写入。

java 复制代码
    @PostMapping("/submit")
    public Boolean submit() {
        long tid = Thread.currentThread().getId();
        log.info("threadId={}", tid);
        OrderInfo orderInfo = new OrderInfo();
        orderInfo.setOrderNo(UUID.randomUUID().toString());
        orderInfo.setAddressId(123321123321123L);
        orderInfo.setMerchantId(123321123321123L);
        orderInfo.setUserId(123321123321123L);
        orderInfo.setOrderAmount(BigDecimal.valueOf(123123L));
        return groupManager.queueGroup(orderInfo);
    }

    @PostMapping("/submit2")
    public Boolean submit2() {
        OrderInfo orderInfo = new OrderInfo();
        orderInfo.setOrderNo(UUID.randomUUID().toString());
        orderInfo.setAddressId(123321123321123L);
        orderInfo.setMerchantId(123321123321123L);
        orderInfo.setUserId(123321123321123L);
        orderInfo.setOrderAmount(BigDecimal.valueOf(123123L));
        return orderInfoService.save(orderInfo);
    }

经过反复实验以及调整组提交的组大小、等待时间参数,得出组大小200,等待时间5ms,得出的TPS是比较好的。TPS达到近8800。接口错误率几乎没有

单提交(每次请求提交一次)所有配置和环境一致的情况下。并发700,循环1000次,70万请求。TPS在5200。错误率0

可以看出组提交比单提交TPS高出68%左右,优化比较明显。如果能针对组大小和等待时间继续调整优化,可能TPS会更高。RT上平均时间比但提交快了1倍,但是P99、P95、P90都比单提交要慢1倍。

相关推荐
BinaryBardC3 小时前
Bash语言的数据类型
开发语言·后端·golang
Pandaconda3 小时前
【Golang 面试题】每日 3 题(二十一)
开发语言·笔记·后端·面试·职场和发展·golang·go
_院长大人_4 小时前
使用 Spring Boot 实现钉钉消息发送消息
spring boot·后端·钉钉
土豆凌凌七4 小时前
GO随想:GO的并发等待
开发语言·后端·golang
AI向前看4 小时前
C语言的数据结构
开发语言·后端·golang
快乐非自愿4 小时前
一文解秘Rust如何与Java互操作
java·开发语言·rust
SomeB1oody4 小时前
【Rust自学】10.8. 生命周期 Pt.4:方法定义中的生命周期标注与静态生命周期
开发语言·后端·rust
小万编程4 小时前
基于SpringBoot+Vue毕业设计选题管理系统(高质量源码,提供文档,免费部署到本地)
java·vue.js·spring boot·计算机毕业设计·java毕业设计·web毕业设计
m0_748235074 小时前
使用rustDesk搭建私有远程桌面
java
快乐是4 小时前
发票打印更方便
java