性能优化实践小记

背景

最近项目里面做了一个分享助力排名中奖的活动,对性能有一定的要求,其实也不高,就 100~200 TPS左右。但是几个周赶出来的项目设计和实现问题都很多,追求快速完成功能,到性能测试这一步就卡住了,于是就有了这次性能优化之旅。

三个方向

一、数据库

首先当然是数据库表设计,使用的是MySQL数据库,主要是表索引以及尽量简单的查询。由于本次我们的业务比较简单,主要涉及几个表:

  • 用户参与记录表:哪些用户参与了我们的活动
  • 用户分享助力关系表:用户之间互相助力的关系记录
  • 用户被助力次数表:主要用于最终的用户助力次数排行的统计

这些表的查询相对简单,基本都是按照用户ID(Long)查询相关数据,所以在表查询这块的优化空间并不大。

然后就是MySQL集群模式,我们采用的是MySQL 8.0 官方推荐的MGR(MySQL Group Replication)模式,问题不大。

二、代码设计与中间件

这块的问题是比较多,也是主要需要记录的。因为初期为了赶工,很多地方的设计都是简单粗暴的实现功能,所以这一块是优化的重点。

1. 引入WebFlux

助力相关接口采用WebFlux包装,利用其异步响应式的设计提高吞吐量,但效果并不明显,因为我们的并发量也不高,仅作为尝试。

2. 利用MQ解耦削峰

点击助力这个接口可以预见是并发最高的接口,里面主要包含几个步骤:

  1. 记录A用户给B用户助力;
  2. 记录B用户的被助力次数;
  3. 更新助力次数排行榜:注意的是,如果是直接利用数据库查询排行榜,就没有这个步骤了。但是我这里使用了Redis实现,所以多了一个步骤。

前两个操作都是写库操作,势必要使用事务保证整个操作的原子性,但遇到的问题是,直接使用Spring的本地事务在测试时TPS一直上不去,猜测可能是跟我们在测试环境进行性能测试,一套数据库集群几十个项目在使用有关系。

由于这个问题比较难定位,时间又紧迫,所以只能从代码层面去优化,主要是引入了RocketMQ,利用其可靠消息保证最终一致性的分布式事务来解耦这几个操作:

  1. 接口只做一次写库操作,就是记录A用户给B用户助力。

  2. 向MQ发送一条事务消息,用于记录B用户被助力的次数以及更新助力排行榜。

要注意两个点:

  1. 需要利用RocketMQ的事务消息机制,先发送prepare消息,再写库,再commit or rollback给MQ。这时Spring的本地事务就可以去掉;
  2. 消费消息时需要针对单个用户加分布式锁,因为服务都是多pod/实例部署,同一个用户的多次助力,可能被不同的实例所消费,为保证数据库记录数据的准确性,需要加锁。这个地方并没有性能要求,更多的是要关注数据的准确与否。

这里主要是利用MQ的异步、解耦、削峰的特性,来实现对高并发下的接口写操作的分离和快速响应。虽然排行榜的更新有一定的延时(毫秒级或者秒级),但是用户感知不大且不是特别关心,因为助力操作本来就是在分享之后的某个不确定时间点发生的,除了系统需要记录精确的时间点以外,用户更关心的是最终的助力数是否正确。

3. Redis实现排行榜

排行榜的查询,最开始是直接利用MySQL的order by + @变量的方式实现排名和排名序号的递增。但是这样的实现在数据量稍大后,性能急速下降,SQL也比较复杂,后面对其重新设计,利用Redis的ZSet来实现排行榜的功能:

  • ZSet的value是用户ID,(初始设计)score是被助力次数,次数越多,排名越高;
  • 利用ZSet的zrangebyscore取前10名即可实现排行榜的查询;
  • 排行榜的更新在MQ消费后向ZSet add即可,因为此命令既可以添加一个新的value,也可以对已经存在的value进行更新。且每消费一条MQ消息即表示某一个用户有一次助力产生,score 默认+1即可。

这里出现了一个很有意思的问题,那就是如果两个人是相同的助力次数,排名怎么算?如果都在或者都不在中奖区间以内,那么谁前谁后都无所谓。但是一旦区间的边缘出现两个助力次数一样的排名,一个中奖一个没有中奖,那么就要必须有合理的解释。

按照正常生活中的逻辑来说,应该是先达到某一助力次数的用户排在前面,即先到先得。但是按上面的实现,从ZSet里面取排名时,助力次数(score)相同的情况下,是按照先参加活动的用户排在前面,即先进入ZSet的value排在前面,这是我实际观察得到的结果。这个取值逻辑在程序里面肯定没有问题,但却不符合此时的业务逻辑。

经过上面的分析,可以看出score需要跟时间挂钩,但如果score直接加上时间戳,那么就是后到先得,不符合我们的逻辑。所以我们可以换一种思路:Java时间精度默认是毫秒,目前生成的时间戳是13位。所以可以用某个14位数字(未来的某一个时间戳)减去当前的时间戳,再以得到的数字作为小数,助力次数作为整数,就成为了最终的score。

\(score = n.(x - y)\) [1]

x 为某14位数字,我取的是10 000 000 000 000,代表的是未来的2286-11-21 01:46:40.000这一刻,这个时间点已经大大超出目前绝大部软件系统的生命周期,因此可以认为它是一个常数。y 即助力那一刻的时间戳。n 是助力次数。

(x - y)得到的是一个逆向时间戳,在助力数n相同的情况下,助力越早,时间戳越小,逆向时间戳越大,最终的score就越大,排名就越靠前。 完美实现了我们想要的效果,先到先得。

4. 预加载、压缩、缓存

除了助力以外,这次活动还有一个比较麻烦的点在于分享二维码的生成。由于我们前端是微信小程序,微信的小程序码接口又只能在服务器端请求且返回的是一个图片Buffer(每个二维码大约110 kb左右),所以去实时生成二维码不但需要多次网络IO,返回的数据量也较大,显然达不到性能要求。

小程序码 > wxacode.getUnlimited

所以针对二维码的优化,主要是从三个方面来进行的:

  1. 预加载

    二维码是一次生成,永久可用,可以利用前端页面逻辑的顺序性,当用户在分享二维码页面之前的某个页面时,提前通知后端进行预生成,并存储起来。

  2. 缓存

    存储时使用到的是Redis和OSS,依据前端的需求有两种方式:

    • 直接存储Base64格式的数据到Redis,接口返回的也是Base64的字符串。
    • 将Base64转化为png文件上传到OSS,再把链接存储在Redis,返回给前端这个链接地址。
  3. 压缩

    主要是针对第一种存储方式,一个二维码图片Buffer大约是110 kb左右,我们测试的用户数据是10w,如果原始数据直接缓存到Redis中,那么将占用:110(kb)×100000÷1024÷1024≈10(gb) 左右的缓存空间,这显然不合理。

    所以便想到了压缩后再进缓存,但必须得保证二维码的清晰程度。经过实际测试,压缩到20kb左右可以兼顾清晰度和空间利用效率,这时只需占用约 1.9gb 缓存空间,这样一个占用相对来说就可以接受了。而且这只是测试预估的最大值,实际生产环境中参与活动的用户数远没有达到这个数量,只有一半左右。

长远来看,在面对更大体量的数据时,压缩也不好使,还是应该预加载 + OSS + Redis最佳。

三、参数调优

这一块主要是JVM和Web容器的参数调优,我们实际生产环境的单个pod资源限制标准为2C4G。

  1. JVM

    主要是以下几个参数,比较通用简单。

    -XX:+UseG1GC 使用G1垃圾收集器
    -Xms4000m    初始化JVM内存,可以设置的和最大内存一致,避免每次GC后JVM重新分配内存以及扩容带来的影响
    -Xmx4000m    最大JVM可用内存
    -XX:MaxGCPauseMillis=100  设置每次年轻代垃圾回收的最长时间,如果无法满足此时间,JVM会自动调整年轻代大小,以满足此值
  2. Web容器

    我们项目使用的是Undertow而不是Tomcat,从网上找了一些通用的参数调整。

    # undertow配置
    server:
      undertow:
        # 设置IO线程数, 它主要执行非阻塞的任务,它们会负责多个连接, 默认设置每个CPU核心一个线程
        # 不要设置过大,如果过大,启动项目会报错:打开文件数过多
        io-threads: 16
        # 阻塞任务线程池, 当执行类似servlet请求阻塞IO操作, undertow会从这个线程池中取得线程
        # 它的值设置取决于系统线程执行任务的阻塞系数,默认值是IO线程数*8
        worker-threads: 256
        # 以下的配置会影响buffer,这些buffer会用于服务器连接的IO操作,有点类似netty的池化内存管理
        # 每块buffer的空间大小,越小的空间被利用越充分,不要设置太大,以免影响其他应用,合适即可
        buffer-size: 1024
        # 是否分配的直接内存(NIO直接分配的堆外内存)
        direct-buffers: true

结果

由于请求链路上还有网关与用户鉴权中心的存在,所以我们也分为了两种情况进行测试,走网关和不走网关,用来定位和排除一些应用服务的问题。当然,最终结果是以真实环境的网络链路为标准的,也就是走网关路由和鉴权。

按照测试部门的要求,使用的工具是Jmeter,几个核心接口线程组的线程数设置为100,永久循环持续5分钟。测试环境开启两个pod,单个pod资源限制2C4G。但是测试环境的数据库集群是多个项目共用的,所以数据库的读写性能在不同时间段测试时表现波动很大,最终取的是一个比较折中的数据。

  • 不走网关:写接口的TPS保持在400左右,读接口600 ~ 1000。
  • 走网关:写接口200左右,读接口300 ~ 400。

本次性能测试的要求并不高,但是对于个人来说也是挺有意义的一次实践,其中的很多思路还是值得记录下来的。


  1. score为一个double数,整数部分为助力次数,小数部分为逆向时间戳。这样助力次数越多,自然score越大,助力次数相同的情况下,小数越大排的越靠前。 ↩︎

性能优化实践小记
https://luckycaesar.github.io/article/性能优化实践小记/
作者
LuckyCaesar
发布于
2021年11月20日
许可协议