资讯动态

JVM与SQL双管齐下:高并发返利结算系统性能调优实战

发布时间:2026/10/21 9:27:57 来源:尧图企业网站定制
上线第一周我就被返利结算任务拖进了ICU。那天大促刚结束凌晨三点我盯着监控面板上飙升的Full GC次数和数据库连接池的活跃曲线看着一批批结算任务排进队列又超时重试整个人都麻了。后来花了差不多两周时间从JVM参数调优、SQL改写到线程池和连接池的重新设计把整个返利结算链路的性能一点点抠了回来。这篇文章就把我踩过的坑、验证过的思路、最后落地的参数配置和优化前后数据完整记录一遍。先说下背景我负责的是一个高并发返利结算系统核心业务是承接电商平台每一笔订单的返利计算与结算入账。订单量峰值大概在每秒5000到8000笔每笔订单要经历返利规则匹配、金额计算、账户入账、明细落库、汇总表更新等步骤。系统不是单机但也没有像大厂那样铺几十台机器一共就8个节点扛着。最初版本上线后在常规流量下表现还行但一旦遇到大促、秒杀级别的峰值服务就开始频繁Full GC、数据库连接不够用、任务积压甚至出现返利重复结算的脏数据。整个问题的核心就是在高并发下JVM内存分配、SQL执行计划、池化资源配置三者之间没有对齐业务特征每一层都在极限边缘挣扎。这篇文章适合谁看如果你正在维护一个订单、结算、积分、佣金类的高并发业务系统或者迟早要面对线上性能问题需要从JVM、数据库、连接池和线程池角度做系统级调优那这篇内容应该能帮你少走不少弯路。我会把每个调整点的推导过程、验证方法、踩坑细节都写清楚你可以直接照着方案去压测和调整。1. 上线第一周我被结算任务拖进了ICU性能问题的真实全貌1.1 场景还原促销结束后的返利结算高峰系统在做什么先把系统要做的事讲清楚否则后面的技术选择都没有落脚点。返利结算系统本质上是一条L型链路分两个阶段。第一阶段是订单产生后实时计算返利第二阶段是结算服务把计算结果批量入账到用户账户。第一阶段偏读多写少规则匹配要查大量配置表第二阶段偏写多读少要更新账户余额、插入流水明细、更新返利汇总表而且所有操作都强依赖数据库事务。我当时的系统架构大致是这样的Web接入层接收订单事件投递到本地队列结算线程池消费队列里的任务每个任务执行时先查账户余额、再算返利金额、然后写明细和更新账户余额最后更新汇总表。数据库是典型的MySQL主从结构主库承接写入事务从库承接查询。大促结束后的峰值时刻订单事件像开闸放水一样涌进来每秒大约有6000到8000个结算任务进入队列每个任务需要执行5次以上的SQL。算下来主库的QPS瞬间飙到3万到4万慢查询日志里堆满了执行时间超过2秒的SQL连接池活跃连接数打到上限队列里的任务越积越多线程池线程全部阻塞在数据库等待上从此整个服务进入“充血”状态。这时候最有意思的一个现象是从外部看CPU使用率不高内存也没满但请求就是大面积超时。原因是线程全部卡在等待数据库资源上真正的计算几乎没有。这就是典型的系统资源看着没满但吞吐已经崩了的案例也说明了高并发性能问题绝不能只盯着一两个指标要顺着链路一层层拆。1.2 我记录的第一份问题清单接手问题那天我先花了一个小时同时拉JVM GC日志、慢查询日志、数据库连接池监控和线程Dump把问题归成了四大类问题层面具体现象直接影响JVMFull GC频率高单次GC停顿接近1秒老年代占用不断上升后陡降线程全局停顿请求RT毛刺严重SQL多条慢SQL单条最严重接近9秒集中在账户明细查询和汇总更新数据库连接长时间被占用连接池耗尽线程池核心线程全部阻塞在DB等待队列无剩余空间新任务无法处理任务积压、超时、拒绝执行连接池活跃连接数打满上限新请求排队等待连接超时后抛异常吞吐下降系统雪崩前兆这四个问题不是独立的它们相互放大。SQL慢导致事务时间变长事务变长导致连接被占用连接耗尽可能导致线程阻塞线程阻塞加上任务堆积又让内存里的任务对象积压最终触发GC问题和OOM风险。所以做性能优化时光调一个JVM或者光优化一条SQL效果永远有限要整体调整让每一个环节都按“返利结算这个业务场景”重新对齐。2. JVM参数调优先把堆和GC策略与结算业务对齐2.1 为什么先说“内存画像”而不是直接堆参数很多文章一上来就扔给你一组JVM参数号称“高并发推荐配置”然后完事。但参数这东西极其依赖业务场景你的对象生命周期、分配速率、存活结构都不一样照抄别人的参数大概率翻车。返利结算系统最有特点的地方在于短生命周期对象极多且存在突发性大对象分配。所谓短生命周期对象就是每个结算任务在计算过程中生成的临时DTO、上下文对象、规则匹配结果这些对象在线程栈和年轻代里快速分配又快速变成垃圾。如果年轻代太小对象来不及在年轻代被回收就晋升到老年代老年代迅速膨胀Full GC频率就上来了。所谓突发性大对象就是每天凌晨定时的批量返利对账任务一下加载几万条订单数据到内存做核对这些大对象直接越过年轻代进入老年代老年代空间一旦不够Mixed GC甚至Full GC就频繁触发。摸清内存画像的方法是压测。我先把系统放在测试环境用录制的大促流量回放跑了一遍同时通过GC日志以及内存采样工具观察三件事年轻代对象分配速率、晋升到老年代的对象大小、老年代空间占用曲线。结果很明确年轻代分配速率最高能达到每秒1.2GBParNew收集器下年轻代回收非常频繁但Promotion Failure风险也很高。老年代在批量对账任务启动时会瞬间多出近1GB的存活对象。2.2 我最终采用的JVM参数组与调整依据结合内存画像我选择了G1垃圾收集器作为主调优方向。有人会说G1不是大堆专用吗我们8个节点每台堆才4GB用G1合适吗实际是合适的因为G1的Region化设计把堆划分成多个小区域对大对象分配更友好而且可以通过暂停时间目标来控制GC停顿比CMS更符合我们需要控制RT毛刺的业务场景。我最终在每台节点上落地的JVM参数是这样的-Xms4g -Xmx4g -Xss512k -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:G1HeapRegionSize8m -XX:ParallelRefProcEnabled -XX:ParallelGCThreads8 -XX:ConcGCThreads4 -XX:G1ReservePercent15 -XX:InitiatingHeapOccupancyPercent35 -XX:AlwaysPreTouch -XX:ExitOnOutOfMemoryError逐一解释关键参数的选择逻辑固定堆大小不使用-Xms和-Xmx不同的值。这是很关键的一步。JVM默认会根据使用情况动态扩展和收缩堆内存但线上高并发场景下堆扩容过程中可能触发额外的GC压力而且每次扩展都会引入不稳定的停顿。直接把初始堆和最大堆设成一样等于告诉JVM你不需要动态调节把所有精力放在内部GC策略上实测下来Full GC频率明显下降。MaxGCPauseMillis设成100ms。这个参数不是设得越低越好。设太低G1会为了追求暂停时间而把年轻代缩得很小导致GC频率飙升总停顿时间反而更长。100ms对我们结算场景够用因为单次结算任务允许的RT波动范围是300ms到500msGC停顿只要控制在这个范围内的毛躁可以接受。如果你做的是毫秒级实时交易那可能需要更激进的50毫秒甚至更低但那样要配合更大的堆空间。G1ReservePercent设为15%。它表示G1为了容纳晋升对象保留的堆空间比例。因为我们的批量对账任务会产生大量一次性的存活大对象如果保留空间不够Mixed GC期间会频繁发生Full GC。15到20是比较稳妥的范围。实测从默认10%调到15%后批量任务触发Full GC的次数降低了近一半。InitiatingHeapOccupancyPercent设为35%。这个参数决定G1何时启动并发标记周期默认值是45%。触发周期越早老年代积累的速度就越慢Mixed GC之后的晋升空间越大。但也不能太低否则后台并发标记线程频繁抢占CPU影响业务。35%是我压测下来对吞吐影响和GC频率更平衡的一个值。AlwaysPreTouch必须打开。它的作用是启动时就把4GB物理内存全部映射并锁入物理内存中而不是等JVM运行到需要时才去申请。实际效果是启动时间变长几秒钟但运行期间内存分配稳定不会因为操作系统延迟分配而出现诡异的性能抖动。高并发服务必须打这个参数。还有一个容易被忽略但很重要的选项是**-XX:ExitOnOutOfMemoryError**。它不是提升性能的而是让JVM在遇到OOM时直接退出而不是半死不活地继续硬撑。结算系统一旦堆内存不足与其让每个线程都卡在OOM异常里死循环不如让进程退出被心跳监测踢掉容器重新拉起一个新节点。这个策略配合负载均衡使用能大幅缩短恢复时间。跑完压测后的GC指标比优化前好很多Full GC次数从高峰期每小时三十多次降到个位数甚至零P99延迟从接近1200毫秒降到300毫秒以内。但这个成绩不是JVM单方面的功劳如果SQL不优化再多内存也扛不住线程阻塞所以接下来看SQL这块。3. SQL优化从一条9秒慢SQL到毫秒级的排查链路返利结算系统的SQL问题其实非常有代表性。业务简单、表结构不算复杂但就是慢。慢的原因是开发阶段“能用就行”的SQL写法在数据量上了千万级、并发上来了之后全部暴露。这一章节我把三条最致命的慢SQL完整展开按排查链路走一遍你会看到优化SQL的一条通用思路先看执行计划再改写法最后拿数据验证。3.1 根因之一IN子查询导致的反连接失效第一条慢SQL出现在结算明细查询这个环节。用户查询自己近三个月的返利订单明细当时写的SQL是SELECT b.id, b.order_no, b.refund_amount FROM user_refund_detail b WHERE b.user_id #{userId} AND b.order_no IN ( SELECT order_no FROM user_order_main WHERE user_id #{userId} AND status 1 ) ORDER BY b.create_time DESC LIMIT 20;初看逻辑也没毛病先查出这个用户的有效订单号再用这些订单号去过滤退款明细。问题是执行计划出来之后MySQL把IN子查询优化成了临时表先全量物化子查询结果再让外层表和物化表做关联。当user_order_main表数据量上到三千万级别、符合条件的数据有几十万条时这个临时表本身就要占用大量内存和磁盘执行时间直线上升。这条SQL在高峰期平均耗时在2到5秒之间最严重的时候跑出过9秒。9秒的时间里这条SQL占着一个数据库连接不放如果每台应用有50个这样的查询并发进来连接池立刻就被拖垮。优化方案并不是单纯地加索引而是把IN子查询重写成JOINSELECT b.id, b.order_no, b.refund_amount FROM user_refund_detail b INNER JOIN user_order_main om ON om.order_no b.order_no AND om.user_id #{userId} AND om.status 1 WHERE b.user_id #{userId} ORDER BY b.create_time DESC LIMIT 20;改写后的执行计划走了索引嵌套循环连接user_id和order_no都命中了复合索引不再物化临时表。执行时间从秒级降到20毫秒左右。值得说明的是这种改写不是所有场景都成立。IN子查询和JOIN最大的区别在于IN去重JOIN可能因为一对多关联把结果放重。我们这里因为订单明细和主订单在order_no维度是一对一的关系所以JOIN安全。如果你的业务里存在一对多关系JOIN之前必须确认不会产生重复行或者改用EXISTS子查询那也是一种更高效的反连接写法。3.2 根因之二OR条件放弃索引的代价第二条慢SQL是账户汇总的定时任务。这个任务每5分钟扫描一次所有未完成入账的返利记录把汇总金额重新计算一遍。SQL长这样UPDATE account_summary SET total_amount total_amount #{addAmount}, update_time NOW() WHERE user_id #{userId} AND (account_type 1 OR account_type 2);问题出在这个OR上。MySQL优化器在面对OR条件时经常判断无法直接利用复合索引于是退化成全表扫描。account_summary表当时有两千多万行每次全表扫描差不多要600多毫秒倒也不至于9秒那么夸张但这个任务每5分钟跑一次全量扫描持续占用CPU和IO高峰期让主库负载多出了15%。排查时我看到EXPLAIN里type一栏是ALLkey是NULL就明白是索引没走。当时有两种改写思路一种是拆分SQL把OR改成UNION ALL因为两个条件覆盖的是互斥的分区可以分别走索引再合并结果另一种是直接改造业务逻辑把account_type这个维度去掉。我选择的是先保留业务语义用UNION ALL改写UPDATE account_summary SET total_amount total_amount #{addAmount}, update_time NOW() WHERE user_id #{userId} AND account_type 1; UPDATE account_summary SET total_amount total_amount #{addAmount}, update_time NOW() WHERE user_id #{userId} AND account_type 2;拆分后每条语句都能命中user_id和account_type的复合索引单条更新耗时降到1毫秒左右。整个任务从600多毫秒缩减到不到10毫秒。这里有个通用经验OR条件在MySQL里往往就是索引杀手能用UNION ALL拆分就不要硬扛。如果两个条件查出来的结果集可能重复用UNION做去重也可以只是多了去重开销。另外如果OR两侧的条件覆盖了同一索引的前缀列实际上MySQL也能用索引合并Index Merge但依赖具体的统计信息不稳定不如改写可靠。3.3 根因之三深分页扫描第三个问题出在管理后台的返利结算记录导出功能。运营同学每天要翻看结算流水翻到几百页之后页面加载就慢得离谱。原SQLSELECT id, order_no, user_id, amount FROM refund_settle_log WHERE create_time BETWEEN #{startTime} AND #{endTime} ORDER BY id DESC LIMIT 100000, 50;LIMIT 100000,50的问题在于MySQL会先把前100050条数据全部扫描出来再丢弃前100000条只返回最后50条。随着页码加深扫描量线性增大整个查询的时间从几十毫秒膨胀到三秒以上。优化方案用的是经典的“延迟关联”思路。先只查主键ID再用主键ID关联回原表取完整数据SELECT t.id, t.order_no, t.user_id, t.amount FROM refund_settle_log t INNER JOIN ( SELECT id FROM refund_settle_log WHERE create_time BETWEEN #{startTime} AND #{endTime} ORDER BY id DESC LIMIT 100000, 50 ) tmp ON t.id tmp.id ORDER BY t.id DESC;子查询里只扫描主键索引不涉及回表取行因为主键索引占用的空间小扫描100050条数据的成本远低于全表行扫描。拿到50个主键后再精确回表取50行数据。优化后这个查询在百万级翻页场景下耗时稳定在100毫秒以内。这套思路也可以进一步演进为书签方式也就是通过WHERE id 上次最大ID来取代LIMIT偏移量。但书签方案对跳页场景不适用管理后台运营同学喜欢随便点页码所以延迟关联更贴合这里实际使用方式。4. 池化技术线程池与连接池的并发预算逻辑SQL优化到毫秒级之后数据库连接占用时间大幅缩短连接池和线程池的瓶颈开始缓解但并不代表池化配置就不需要调整。事实上返利结算系统真正的并发控制核心就是这两个池子。这里我把线程池的计算过程和连接池参数的推导完整展开这部分网上靠谱的干货非常少大多是口号式建议没有完整推导过程。4.1 连接池数量到底怎么算很多人觉得连接池越大越好先把maxActive调成500最多再加大到1000结果发现数据库CPU飙升、请求反而更慢。这是典型的无预算并发思维。连接池连接数一旦超过数据库能同时有效处理的量多余连接就是纯粹的资源争抢。通用公式网上有人给过连接数 核心数 x 2 有效磁盘数这个公式适合IO密集型场景但更准确的应该是Little定律的一种变体并发任务数 单位时间任务数 x 单个任务耗时。对我们这个系统来说单个结算事务包含5到8条SQL事务在数据库端的平均执行时间大约25毫秒到35毫秒。QPS峰值按6000计算那数据库同时需要处理的在途事务数就是6000乘30毫秒也就是180个左右。连接池的上限设定在总在途事务数的1.2到1.5倍比较合理也就是250个左右。我当时是这么算的单事务完整DB占用时间 5次SQL平均耗时约30ms QPS峰值 6000事务/s 连接数 6000 x 0.03 180 保障系数 1.3 最终连接池maxActive 240那240个连接会不会太多我们主库配置是16核64GB连接本身占用的内存很低但CPU能同时处理的事务有限所以240不是从内存角度定的而是从“不让连接长时间空等”的角度定的。数据库端单事务平均30毫秒如果有240个活跃连接每个连接的等待时间就在合理范围。如果设成500个以上反而会因为事务并发过多导致锁等待加剧、单事务耗时增加形成恶性循环。还需要说明的是连接池并不是只有maxActive一个参数。initialSize、minIdle、maxIdle、maxWait都必须对齐。我在实际配置里用的是initialSize5minIdle10maxIdle20maxActive240maxWait3000。启动时只创建5个连接预热完成后逐步升到minIdle到maxIdle区间峰值时扩容到maxActive空闲下来也不会立刻归还太多。maxWait设3000毫秒是因为结合前面所有优化连接获取理论上不应该超过50毫秒超过3秒还在等待连接就说明系统已经严重过载继续等下去只会拖垮更多线程不如直接抛出快速失败的异常。4.2 线程池参数与拒绝策略的选择线程池的计算比连接池更依赖业务类型。返利结算的任务属于典型的IO密集型任务因为结算过程要查数据库、调用RPC、等待远程结果真正的CPU计算占比很小。IO密集型的线程池大小理论上可以按CPU核心数的2到4倍估算但这种估算太粗。更务实的做法是先分析任务的阻塞占比然后通过压测校准。我当时统计出来的任务结构是这样平均每个结算任务总耗时40毫秒其中CPU计算约5毫秒IO等待约35毫秒阻塞占比87.5%。公式线程数 CPU核心数 / (1 - 阻塞系数)以每节点8核计算8 / (1 - 0.875) 64。但因为数据库连接池只有240个连接分布在8个节点上每个节点最多分到30个连接。如果线程池设成64个那就会造成将近一半的线程在等待连接白白浪费。所以我把线程池核心线程数设成了32最大线程数也是32队列容量设成2000。这里有一个关键决策核心线程数和最大线程数相同队列有限。这样做的目的是放弃“核心线程不够就扩到最大线程”这种弹性伸缩转而使用有限队列加拒绝策略来兜底。原因很简单如果核心线程撑不住场景说明系统容量已经到顶了。此时继续扩线程只会让更多线程去抢连接池里有限的连接整体吞吐不会上升反而因为上下文切换拖慢已有任务。队列容量2000不是拍脑袋定的。这个值取决于任务在队列里允许等待的最长时间上限。大促峰值时每秒来6000个任务节点分摊750个每秒32个线程实时消费单个任务40毫秒每秒每个线程大约能消费25个任务总计800个略高于入队速率。这种情况下队列几乎不会积压。但如果峰值持续时间长任务开始积压2000的队列意味着最多还能缓冲约2.7秒的任务量。如果原始入队速率持续超过消费能力超过这个时间就得直接触发拒绝策略。拒绝策略我选择的是CallerRunsPolicy。它的逻辑是当队列满且线程无法处理新任务时不丢弃任务也不抛出异常而是把该任务回推给提交任务的调用者线程去执行。放到我们链路里意味着如果结算服务忙不过来订单事件就在接入层线程里同步执行这相当于把压力往上游回推让接入层的线程池来扛当接入层也扛不住时最终会体现在消息队列的积压上。用这个消息队列积压来做背压比在结算服务里直接丢弃任务要安全得多。因为返利结算丢了就是财务损失宁可慢也不能少。线程池还有一个被大多数人忽略的配置是allowCoreThreadTimeOut。默认情况下核心线程即使空闲也不会销毁这在高并发系统里是有价值的因为避免频繁创建线程。我把它保持默认。同时我设置了线程工厂统一命名前缀“settlement-worker-”这样每次线程Dump出来一眼就能看清业务线程在哪个池子里。5. 优化后的压测对比与上线观察5.1 三类指标的前后变化整个调优不是做一步看一步而是把JVM参数、SQL改写、池化参数集中到一个版本里通过录制流量回放和全链路压测分三轮验证。第一轮只上线JVM参数调整观察GC变化第二轮加入SQL改写观察数据库慢查询和连接池第三轮才调整线程池和连接池参数观察整体吞吐与稳定性。最终压测结果整理成了一张表指标优化前优化后峰值QPS约4500开始明显超时7500仍稳定响应P99 RT1200ms以上250ms以内Full GC次数/小时18到35次0到2次单次GC最长停顿950ms110ms最严重慢SQL耗时9000ms25ms连接池活跃占用峰值100%持续超时65%任务积压数峰值超2万积压峰值不超过100这组数据说明瓶颈不在CPU不在内存而在于慢SQL把连接和线程全部拖死JVM又因为对象积压频繁Full GC。一旦三层同步优化整个系统就像通了堵塞的下水道一样流量顺畅地流过每一道闸口。5.2 上线后仍需持续盯住的余量风险优化完成后系统运行稳定但并不代表一劳永逸。我整理了三个上线后需要持续关注的风险点也是后来在另外两个项目中反复踩到过的。第一是LRU缓存和本地缓存对JVM堆的影响会随业务迭代漂移。返利规则匹配原本常驻了一部分热数据在本地缓存里如果运营频繁增加新规则或规则匹配的逻辑变了缓存容量和对象生命周期就会发生变化堆内存画像随之变化原先那套G1参数可能需要重新校准。我的建议是每个月拉一次GC日志做例行体检观察老年代占用曲线是否还在预估区间不要把参数调完就当甩手掌柜。第二是数据库数据量增长对SQL执行计划的潜在冲击。优化后的SQL目前是毫秒级但数据库的统计信息会随数据量变化优化器选择执行计划的依据也在变化。最初我们优化完的那条JOIN语句三个月后因统计信息漂移执行计划从INLJ变成了BNL性能又出现了衰减。因此慢查询日志不能只在新版本上线时看而是要建立一套“慢查询日报”机制每天自动抓取超过200毫秒的SQL并通知负责人。第三是线程池队列容量和消息队列积压的联动需要定期演练。CallerRunsPolicy意味着压力会传递到消息队列或者上游RPC调用方如果下游始终无法消化最终上游会拒绝更多请求。这种时候必须有一个降级开关比如关闭非核心的返利明细实时查询或者对部分交易类型切换到异步批处理模式。这些开关要在大促前实际演练一遍确保真出事的时候能够一键触发而不是临时看代码再去现找配置。我在实际运营中还发现一个小小的技巧值得分享压测时不要只测正常场景一定要测“慢SQL复活”场景。也就是人为在数据库某张表上加一个锁模拟一条慢SQL产生的效果观察连接池和线程池的连锁反应。真实生产事故里很多系统崩溃的第一因就是慢SQL意外复活而你只有提前跑过这种故障注入演练才知道当前池化参数在极端情况下的退化行为是否符合预期。整个优化过程给我最大的体会是所谓高并发性能调优不是去背一串神秘的JVM参数也不是把某条SQL写得多花哨而是把你系统的真实业务画像摸清楚然后让每种资源都在自己该在的位置上待命。连接池里的连接数量要对齐数据库的在途事务数线程池里的线程数量要对齐数据库能提供的连接数JVM的GC参数要对齐对象的分配和存活特征SQL的写法要对齐查询条件和索引的匹配方式。四层对齐做到位系统自然就会稳得住。希望这篇记录能给你一些参考尤其是那些正在跟结算类、返利类系统搏斗的朋友少走点弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑