慢接口优化实战:从7秒到300毫秒的华丽转身

慢接口优化实战:从7秒到300毫秒的华丽转身 摘要getUserList 接口飙到 7 秒Arthas 追踪发现 99% 耗时压在一条 SQL——7000 用户 id 的大 IN 列表 积分日志表 GROUP BY/HAVING SUM。分两轮优化才看清账去掉大 IN 列表改 JOIN 只降到 3 秒2.3×建汇总表干掉实时 SUM 才降到 300 毫秒10×。25 倍提升里大头来自消灭聚合不是那个看着最吓人的 IN 列表。附预聚合的数据延迟代价与同步方案取舍。有时候性能优化就像是一场侦探游戏——你永远不知道下一个线索会把你引向何方。这次我们的目标是把一个需要7秒才能响应的接口变成一个秒回的敏捷小能手。案发现场一个让人焦虑的慢接口故事要从一个平凡的周二早晨说起。监控系统突然亮起了红色警报显示getUserList接口平均响应时间飙升到了7秒多。对于用户来说7秒钟足够他们去泡杯咖啡了——但我们显然不希望他们有这个机会。使用Arthas工具进行现场诊断我们得到了这样的调用链数据Affect(class count: 1 , method count: 1) cost in 984 ms, listenerId: 1 ---ts2025-05-27 10:34:12.252;thread_namehttp-nio-7001-exec-43;id492;is_daemontrue;priority5;TCCLorg.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader155767a7 ---[6010.759783ms] com.example.user.service.impl.UserService:getUserList() ---[0.00% 0.00471ms ] ... ---[86.56% 5202.959234ms ] com.example.user.service.impl.UserService:fetchUserData() #144 ---[0.00% 0.00438ms ] ... ---[13.37% 803.745826ms ] com.example.user.service.impl.UserService:queryAdditionalData() #160 ---[0.02% 0.948998ms ] ...进一步深入fetchUserData方法真相逐渐浮出水面---ts2025-05-27 10:44:12.661;thread_namehttp-nio-7001-exec-20;id414;is_daemontrue;priority5;TCCLorg.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader155767a7 ---[6875.670671ms] com.example.user.service.impl.UserService:fetchUserData() ---[0.00% 0.00379ms ] ... ---[0.48% 33.073733ms ] com.example.common.dao.UserStatusDao:findActiveUserListBySegment() #998 ---[0.00% 0.00276ms ] ... ---[99.14% 6816.629213ms ] com.example.common.dao.UserPointDao:findQualifiedUsersByPoints() #1002 ---[0.04% min3.4E-4ms,max0.00169ms,total2.706507ms,count7104] ...看到这里问题已经很明显了99.14%的时间都消耗在了一个数据库查询上这几乎就是罪魁祸首。破案时刻大IN列表的性能陷阱经过进一步分析我们发现了问题的根源。在很多业务场景中我们经常会遇到需要使用IN操作符来筛选大量数据的情况这就是典型的大IN列表问题。原来的实现逻辑如下ListUserInfousersuserInfoDao.findActiveUserListBySegment(targetSegment,UserType.getActiveValue());if(CollectionUtils.isNotEmpty(users)){//积分过滤IntegerthresholdConfigCache.getIntValue(AppConfig.USER_RICH_THRESHOLD,800);ListStringqualifiedUsersuserPointRecordDao.findUsersByPointThreshold(threshold,users.stream().map(UserInfo::getUserId).collect(Collectors.toList()),batchSize);for(UserInfouser:users){if(!qualifiedUsers.contains(user.getUserId())){continue;}resp.add(newUserQueryResult(user.getUserId(),String.valueOf(user.getLastActivityTime().getTime()),user.getCurrentAssignee()));}}对应的SQL语句更是让人大跌眼镜SELECTuser_id,current_assignee,last_activity_timeFROMt_user_infoWHERE11AND(current_assigneeisnullorcurrent_assignee)andsegment_codepremium_segmentANDuser_typeIN(101,102,103,109,110,111)-- in里面有7000个userId是上一步的查询结果SELECTuser_idFROMt_user_points_logWHERE11anduser_idin(1001240051278,...,9999235047526)GROUPBYuser_idHAVINGSUM(point_value)800看到那个包含 7000 多个 ID 的 IN 子句了吗第一眼看上去它就是元凶。但先别急着下结论——这条 SQL 里其实藏着两个嫌疑人嫌疑人干了什么①7000 个 ID 的 IN 列表SQL 文本膨胀到上百 KB每次执行都要重新解析②GROUP BY user_id HAVING SUM(point_value) 800对这些用户的全部积分流水做实时聚合直觉会指向 ①因为它最扎眼。但后面两轮优化的数据会告诉我们真正的大头是 ②。先记住这个悬念我们一轮一轮拆。⚠️ 顺便纠正一个常见说法大 IN 列表不会让索引「失效」。MySQL 对常量 IN 列表会排序后做二分查找索引照用它带来的主要是解析开销和命中行过多时优化器改走全表扫。这两件事跟「索引失效」是两码事——详见 《深入剖析 MySQL 中 NOT IN 语句的性能陷阱与优化实战》那篇专门拆了大 IN 列表到底慢在哪。第一轮优化化繁为简的JOIN大法发现问题后我们决定先进行第一轮优化。核心思路是减少数据库交互次数并优化SQL查询逻辑。我们将原来的两次查询合并为一次JOIN查询SELECTm.user_id,current_assignee,last_activity_timeFROMt_user_infoasmJOIN(SELECTuser_idFROMt_user_points_logGROUPBYuser_idHAVINGSUM(point_value)800)rONm.user_idr.user_idWHERE(current_assigneeISNULLORcurrent_assignee)ANDsegment_codepremium_segmentANDuser_typeIN(101,102,103,109,110,111)LIMIT300;这次优化确实带来了显著的效果——查询时间从7秒多降低到了3秒左右---ts2025-05-27 19:04:32.401;thread_namehttp-nio-7001-exec-9;id293;is_daemontrue;priority5;TCCLorg.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader4d529dbf ---[3066.44368ms] com.example.user.service.impl.UserService:fetchUserData() ---[0.00% 0.00242ms ] ... ---[99.94% 3064.744957ms ] com.example.common.dao.UserStatusDao:findRichUserListBySegment() #1003 ---[0.01% 0.156842ms ] org.apache.logging.log4j.Logger:info() #1023虽然有了明显改善但 3 秒仍然不够理想。注意这里的关键信息第一轮已经把 7000 个 ID 的 IN 列表整个干掉了改成 JOIN 子查询结果只从 7 秒降到 3 秒。如果 IN 列表真是核心元凶去掉它应该一步到位才对。它没有。这说明剩下的 3 秒另有其人——就是那个还留在子查询里的GROUP BY user_id HAVING SUM(point_value) 800。优化效果对比数据不会说谎优化前后效果对比一目了然优化前2025-05-27早7点初步优化后2025-05-28早7点虽然第一轮优化已经取得了一定成效但我们的追求不止于此。第二轮优化引入汇总表的终极方案既然JOIN查询仍有性能瓶颈我们决定采用更激进的优化策略——预计算和缓存。这是数据库优化中的经典套路用空间换时间。创建用户积分汇总表我们新建了一个专门用于统计用户积分的汇总表-- 新增用户积分汇总表CREATETABLEt_user_points_summary(idbigint(20)unsignedNOTNULLAUTO_INCREMENTCOMMENT主键id,user_idvarchar(100)CHARACTERSETutf8mb4COLLATEutf8mb4_unicode_ciNOTNULLCOMMENT用户user_id,total_pointsint(11)NOTNULLCOMMENT积分总数,update_timedatetimeDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT更新时间,create_timedatetimeDEFAULTCURRENT_TIMESTAMPCOMMENT创建时间,PRIMARYKEY(id),UNIQUEKEYuniq_user_id(user_id),KEYidx_userid_points(user_id,total_points))ENGINEInnoDBAUTO_INCREMENT200329DEFAULTCHARSETutf8mb4COMMENT用户积分汇总表;-- 初始化积分汇总表INSERTINTOt_user_points_summary(user_id,total_points)SELECTuser_id,SUM(point_value)AStotalFROMt_user_points_logGROUPBYuser_id;终极查询优化有了汇总表后最终的查询变得异常简单高效SELECTm.user_id,m.current_assignee,m.last_activity_timeFROMt_user_infomJOINt_user_points_summary rONm.user_idr.user_idWHERE(current_assigneeISNULLORcurrent_assignee)ANDm.segment_codepremium_segmentANDm.user_typeIN(101,102,103,109,110,111)ANDr.total_points800LIMIT300;这种优化方式的妙处在于原本需要实时计算 SUM 和 GROUP BY 的操作现在变成了简单的表关联查询。到这里前面的悬念可以收口了——把两轮的账分开算轮次消除了什么耗时变化贡献第一轮7000 个 ID 的大 IN 列表7s → 3s约 2.3×第二轮实时SUM 聚合3s → 300ms约 10×25 倍的总提升里大头来自干掉实时聚合不是干掉 IN 列表。这也符合直觉——只要仔细想一下这两件事的量级IN 列表再长也就 7000 个值而SUM(point_value)要把这 7000 个用户的每一条积分流水都读出来加一遍那是几十万甚至上百万行。扎眼的不一定是最贵的。一条能带走的经验一条 SQL 里同时有「大 IN 列表」和「聚合函数」时先怀疑聚合。IN 列表的成本随值的个数线性增长聚合的成本随被聚合的明细行数增长——后者通常大一到两个数量级。最终效果从龟速到光速的蜕变经过两轮优化最终效果令人满意优化前后对比从7.5秒到300毫秒从图中可以清晰地看到接口响应时间从平均7.5秒大幅下降到了300毫秒左右性能提升了25倍用户再也不用担心点击按钮后需要等待漫长的7秒钟了。技术总结四次关键优化决策回顾整个优化过程我们主要采取了以下四个关键步骤1. Controller入口加锁机制在Controller层添加分布式锁防止高并发请求同时访问慢接口避免多个相同请求同时压垮数据库使用Redis分布式锁控制同一时间只有一个请求处理相同数据RestControllerRequestMapping(/api/users)publicclassUserController{AutowiredprivateRedisTemplateString,ObjectredisTemplate;GetMapping(/list)publicResponseEntityListUserQueryResultgetUserList(RequestParamStringsegment){StringlockKeyuser_list_lock:segment;StringlockValueUUID.randomUUID().toString();try{// 获取分布式锁设置超时时间30秒BooleanacquiredredisTemplate.opsForValue().setIfAbsent(lockKey,lockValue,Duration.ofSeconds(30));if(!acquired){// 如果获取锁失败返回缓存数据或提示稍后重试log.warn(Failed to acquire lock for segment: {},segment);returnResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(getCachedUserList(segment));}// 执行业务逻辑ListUserQueryResultresultuserService.getUserList(segment);returnResponseEntity.ok(result);}finally{// 释放锁需要使用Lua脚本保证原子性releaseLock(lockKey,lockValue);}}privatevoidreleaseLock(Stringkey,Stringvalue){StringluaScriptif redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end;redisTemplate.execute(newDefaultRedisScript(luaScript,Long.class),Collections.singletonList(key),value);}}2. 查询逻辑优化将两次独立的数据库查询合并为一次JOIN查询减少了数据库连接和网络传输开销从7秒优化到3秒效果显著但还不够3. 引入汇总表策略创建t_user_points_summary表进行预聚合将复杂的实时计算转化为简单的表关联从根本上解决了大数据量聚合查询的性能问题4. 数据同步机制这是汇总表方案唯一真正的代价也是最容易被一句「定时刷新就行」带过去的地方汇总表天然是滞后的。原文这里本想写「确保实时性和准确性」——但定期刷新和实时性本身就是矛盾的只能二选一。诚实的说法是用一段可控的数据延迟换 10 倍的查询速度。所以先问一句这个场景能接受多久的延迟本例是「积分超过 800 的用户列表」晚几分钟纳入完全无感但如果是账户余额、库存这类读完立刻要做决策的数据预聚合就不能这么用。同步方式按延迟要求选方式延迟代价定时全量重算分钟~小时级实现最简单但表一大就重算不动积分变更时同步增量更新近实时侵入业务代码且要处理失败重试binlog 订阅Canal 等异步更新秒级不侵入业务但多一套组件要维护必须有兜底对账无论哪种方式都要有一个低频的全量重算任务来纠偏——增量同步一旦漏了一条误差会永久累积下去而且没人会发现。这一节压成一张图开发感悟慢查询的三大陷阱通过这次优化我总结出了慢查询的几个常见陷阱被最扎眼的那段 SQL 带偏7000 个 ID 的 IN 列表看着最吓人实际只占 2.3 倍的提升真正吃掉 10 倍的是那个不起眼的SUM(point_value)。一条 SQL 里同时有大 IN 列表和聚合函数时先怀疑聚合——前者的成本随值的个数增长后者随被聚合的明细行数增长通常差一到两个数量级。缺乏预聚合思维需要反复计算的结果应考虑预先算好存起来。但要认清它的代价是数据延迟见上一节先确认业务能接受。一次只改一处才知道是谁的功劳这次分两轮上线才把「IN 列表贡献 2.3×、聚合贡献 10×」拆得清清楚楚。如果两个优化一起上只会得到一个「快了 25 倍」的结论下次遇到类似问题依然抓瞎。写在最后这次优化经历再次证明了磨刀不误砍柴工的道理。面对性能问题不要急于修修补补而是要从根本上找到问题的症结。有时候一个小小的架构调整就能带来质的飞跃。当然优化也不是一蹴而就的。我们需要在性能、复杂度、维护成本之间找到平衡点。在这个案例中我们通过引入汇总表的方式既解决了性能问题又保持了系统的可维护性。记住每一个慢查询背后都隐藏着一个等待被发现的故事。只要我们有足够的耐心和正确的方法总能找到最佳的解决方案。延伸阅读9 条数据查 11 秒xxl-job 列表慢的索引救援实战 —— 慢查询优化的另一路解法先用 EXPLAIN 定位再用联合覆盖索引根治一条 SQL 扫描 11 亿行CPU 直接拉满字符集不一致引发的线上血案 —— 同为慢 SQL凶手换成隐蔽的字符集隐式转换缺索引引发的「蝴蝶效应」一次死锁事故的深度复盘 —— 索引缺失不止拖慢接口严重时还会全表扫描引发死锁深入剖析 MySQL 中 NOT IN 语句的性能陷阱与优化实战 ——专门拆「大 IN 列表到底慢在哪」不是逐一比较而是 SQL 文本解析开销与本文互为补充最后更新2026-08-03订正了「谁才是元凶」的判断。原文把 7000 个 ID 的大 IN 列表称作「性能问题的核心所在」但本文自己的两轮数据不支持这个结论第一轮把 IN 列表整个去掉只从 7 秒降到 3 秒2.3×第二轮建汇总表消灭实时SUM聚合才从 3 秒降到 300ms10×。25 倍里的大头来自干掉聚合不是干掉 IN。如果你照旧版结论去优化 IN 列表方向就偏了。另新增两点大 IN 列表不会让索引「失效」预聚合真正的代价是数据延迟原文「确保实时性」与定时刷新自相矛盾。️ 标签慢接口优化MySQL大IN列表Arthas汇总表预聚合