高并发热门博客接口优化学习文档(阶段四及以后)
前言
解决关联数据未缓存------再次触发连接池耗尽
问题本质(为什么再次出现连接耗尽?)
博客主表缓存生效后,高并发压测再次出现连接池耗尽异常,报错定位到UserMapper.selectBatchIds,核心根因:
缓存只做了一半------博客列表数据被缓存了,但批量填充用户信息的queryBlogUserBatch方法,还是每次都调用userService.listByIds(userIds)直接查数据库,高并发下大量请求同时查用户表,再次占满连接池。
关键认知:高并发接口优化必须"全链路缓存",接口涉及的所有数据库查询(主表、关联表)都必须做缓存,否则关联表的查询仍会成为性能瓶颈,导致之前的优化前功尽弃。
排查与思考过程
-
第一步:定位瓶颈------通过日志发现,
userService.listByIds(userIds)频繁执行,大量请求打用户表,导致连接池耗尽; -
第二步:明确需求------需要给"用户批量查询"加缓存,且要解决两个核心难点:
-
难点1:用户ID集合是
Set(无序、不重复),相同ID集合顺序不同(如{1,2}和{2,1}),会生成不同的缓存Key,导致重复查库; -
难点2:分布式锁粒度要细,不能所有用户批量查询都抢同一个锁,否则会出现锁冲突,导致性能瓶颈;
-
第三步:推导解决方案------
-
解决难点1:对用户ID进行排序,再拼接成字符串,确保相同ID集合生成唯一的缓存Key(如{1,2}和{2,1}排序后都是"1,2");
-
解决难点2:把"排序后的用户ID字符串"作为
queryWithLogicalExpire的id参数传入,让锁Key = 锁前缀 + 排序后的ID字符串,每个用户集合对应唯一的锁,避免锁冲突。
关键思考:缓存Key的唯一性和分布式锁的细粒度,是高并发缓存设计的核心,必须避免"相同数据不同Key""不同数据同一锁"的问题,否则会引入新的性能瓶颈。
架构职责边界优化------服务解耦
一、优化思路
高并发系统的核心架构要求是"高内聚、低耦合",即每个服务只负责自己核心的职责,不掺杂其他模块的业务逻辑,这样才能降低代码维护成本、支持后续扩展,避免出现"牵一发而动全身"的问题。
结合本次优化场景,我们的核心思路的是:明确"基础服务"与"业务服务"的职责边界------基础服务(如UserService)只提供通用的原子化数据能力,不耦合任何业务定制化逻辑;业务定制化逻辑(如缓存规则)由使用该逻辑的业务服务(如BlogService)负责,遵循"谁使用、谁负责"的原则。
为什么要这么做?核心原因有3点:
-
避免职责混乱:UserService的核心职责是"用户数据的CRUD",若耦合Blog模块的缓存逻辑,会导致UserService职责膨胀,后续维护时无法快速定位核心功能;
-
支持扩展:若后续Comment、Order等模块也需要查询用户并设置不同的缓存规则,无需修改UserService,只需在对应业务服务中实现,符合"开闭原则"(扩展功能不修改原有代码);
-
降低耦合风险:缓存规则属于Blog模块的业务需求,若Blog模块的缓存策略变更(如修改TTL、锁Key),只需修改BlogService,不会影响UserService的正常运行,减少故障传播。
二、问题场景
前期优化中,我们将带缓存的用户批量查询方法(listByIdsWithCache)放在了UserService中,出现了明显的架构耦合问题:该方法是为Blog模块定制的------用于查询博客作者信息并缓存,与UserService的核心职责(用户基础操作)无关,导致UserService既负责用户数据管理,又负责Blog模块的缓存逻辑,职责混乱。
三、实现方案
基于"基础服务纯净、业务逻辑归位"的思路,我们采用轻量易落地的方案,适配中小型项目,具体步骤如下:
-
缓存逻辑迁移:将用户批量查询的缓存逻辑(listUsersWithCache)从UserService移至BlogService,作为BlogService的私有方法,仅服务于当前模块的业务需求(查询博客作者信息);
-
UserService回归核心职责:删除UserService中所有缓存相关代码,仅保留MyBatis-Plus提供的基础listByIds方法,专注于用户基础数据的查询,不掺杂任何业务定制化逻辑;
-
解耦核心保障:BlogService仅依赖UserService的基础原子方法(listByIds),缓存规则(如缓存TTL、锁Key前缀)完全由BlogService自主控制,与UserService互不影响,实现"低耦合"。
核心代码示例(BlogService内封装缓存逻辑,体现"谁使用、谁负责"):
java
// BlogService内私有方法,仅服务于当前模块的缓存需求
private List<User> listUsersWithCache(Set<Long> userIds) {
// 1. 排序用户ID,保证相同集合生成唯一Key(避免缓存重复,属于Blog模块的缓存规则)
List<Long> sortedUserIdList = new ArrayList<>(userIds);
Collections.sort(sortedUserIdList);
String sortedUserIdStr = StrUtil.join(",", sortedUserIdList);
// 2. 调用通用缓存工具类,实现带锁的逻辑过期缓存(缓存规则由BlogService控制)
return cacheClient.queryWithLogicalExpire(
CACHE_USER_LIST_KEY, sortedUserIdStr,
new TypeReference<List<User>>() {},
(id) -> userService.listByIds(userIds), // 仅依赖UserService基础方法
30L, TimeUnit.MINUTES, // 缓存TTL由Blog模块决定
LOCK_USER_LIST_KEY
);
}
四、优化效果
通过上述方案,实现了服务职责的清晰划分,达到了"高内聚、低耦合"的目标:
-
维护成本降低:后续修改Blog模块的缓存规则(如调整TTL),仅需修改BlogService,无需改动UserService;
-
扩展更灵活:其他模块需要查询用户并添加缓存时,可在自身服务内实现,不影响UserService和BlogService;
-
代码可读性提升:每个服务的核心职责明确,开发者可快速定位功能代码,降低排查问题的成本。
分布式锁自旋逻辑优化------解决长尾请求与惊群效应
一、优化思路(核心:为什么要优化自旋逻辑?)
分布式锁的核心作用是"解决高并发下的缓存重建竞争",而自旋逻辑是分布式锁的关键------未获取到锁的线程,通过"短暂休眠+重试"的方式等待锁释放,若自旋逻辑不合理,会导致两个核心问题:长尾请求(部分请求阻塞时间过长)和惊群效应(大量线程同时竞争锁,增加CPU开销)。
结合C端业务的核心需求(可用性优先),我们的优化思路是:
-
避免无限阻塞:给自旋设置"最大重试次数",防止线程一直休眠重试,导致请求长时间阻塞(超过3秒上线红线);
-
减少惊群效应:采用"指数退避"的休眠策略,让未获取锁的线程按递增的时间休眠,避免所有线程同时唤醒、竞争锁;
-
优先保证可用性:自旋超时后,不抛异常,而是降级返回可用数据(如null、旧数据),符合C端业务"数据最终一致性>强一致性"的原则;
-
保证锁操作安全:仅当线程成功获取锁时,才执行释放操作,避免误释放其他线程持有的锁,引发并发问题。
为什么要这么做?核心原因有4点:
-
无限自旋会导致CPU空转:若线程一直休眠重试,会占用大量CPU资源,拖慢整个系统的吞吐量;
-
固定休眠时间会引发惊群效应:所有线程同时休眠50ms后重试,会导致大量线程同时竞争锁,瞬间增加CPU和Redis的压力;
-
粗暴抛异常会影响用户体验:C端用户更关注"请求能否响应",而非"数据是否绝对实时",抛异常会导致用户看到报错,降低体验;
-
误释放锁会引发并发问题:若线程未获取到锁,却执行释放操作,可能释放其他线程持有的锁,导致缓存重建出现并发安全问题(如重复查库)。
二、问题场景
优化前,分布式锁的自旋逻辑存在明显缺陷:无重试次数限制、固定50ms休眠时间,导致压测中出现接口最大响应时间2909ms(长尾请求),CPU空转严重,部分线程因无限自旋导致阻塞,影响系统稳定性。
三、实现方案
基于"指数退避+次数限制+超时降级+安全锁释放"的思路,我们设计了可落地的优化方案,具体步骤如下:
-
设置最大重试次数和基础休眠时间:
-
最大重试次数设为8次,结合指数退避策略,总休眠时间约2.5秒(10+20+40+80+160+320+640+1280ms),不超过3秒上线红线;
-
基础休眠时间设为10ms,后续每次重试的休眠时间按2的幂次递增(指数退避),避免惊群效应。
-
-
实现指数退避自旋逻辑:未获取到锁的线程,按"10→20→40→80ms"的递增时间休眠,每次休眠后重试,直至达到最大重试次数。
-
自旋超时降级:若重试8次仍未获取到锁,不抛异常,返回null(或旧数据),优先保证请求不失败,后续由其他线程完成缓存重建。
-
安全锁释放:添加锁获取标记(isLockSuccess),仅当线程成功获取锁时,才执行释放锁操作,避免误释放。
-
异常友好处理:捕获InterruptedException(线程休眠被中断),恢复线程中断标记,降级返回null,符合Java线程规范,避免业务异常。
-
全链路日志埋点:添加锁获取、释放、重试、降级的日志,便于上线后排查锁竞争问题(如重试频繁、超时过多)。
核心代码示例(优化后的自旋逻辑,体现思路落地):
java
// 提取常量(便于配置和维护,对应"次数限制+基础休眠时间"思路)
private static final int MAX_RETRY_COUNT = 8; // 最大重试次数,避免无限阻塞
private static final long BASE_SLEEP_TIME = 10; // 基础休眠时间(ms),用于指数退避
// 优化后的自旋逻辑(缓存不存在时,加锁查库环节)
int retryCount = 0; // 重试计数器,用于控制最大重试次数
boolean isLockSuccess = false; // 锁获取标记,用于安全释放锁
String lockKey = lockKeyPrefix + id;
try {
// 指数退避自旋逻辑(核心:避免惊群效应)
while (retryCount < MAX_RETRY_COUNT) {
// 获取分布式锁
isLockSuccess = tryLock(lockKey);
if (isLockSuccess) {
break; // 拿到锁,退出重试,执行后续查库逻辑
}
// 指数退避计算当前休眠时间(10→20→40...)
long sleepTime = (long) Math.pow(2, retryCount) * BASE_SLEEP_TIME;
log.info("分布式锁重试:key={},重试次数={},休眠时间={}ms", lockKey, retryCount + 1, sleepTime);
Thread.sleep(sleepTime); // 按递增时间休眠,避免同时唤醒
retryCount++;
}
// 自旋超时降级(核心:可用性优先)
if (!isLockSuccess) {
log.warn("分布式锁重试超时,key={}", lockKey);
return null; // 降级返回,不抛异常
}
// 拿到锁后,执行查库+缓存回填逻辑(核心业务)
result = dbFallback.apply(id);
// ... 后续缓存回填逻辑(省略)
} catch (InterruptedException e) {
log.error("分布式锁自旋休眠被中断,key={}", lockKey, e);
Thread.currentThread().interrupt(); // 恢复中断标记,符合线程规范
return null; // 降级返回,避免业务异常
} finally {
// 安全释放锁(核心:仅释放自己持有的锁)
if (isLockSuccess) {
unLock(lockKey);
}
}
四、优化效果
通过上述方案,彻底解决了自旋逻辑带来的长尾请求和惊群效应问题,达到了预期目标:
-
消除长尾请求:接口最大响应时间从2909ms降至1158ms,控制在1.2秒以内,远低于3秒上线红线;
-
降低CPU开销:指数退避策略减少了惊群效应,锁竞争的CPU使用率降低30%~50%,系统吞吐量更稳定;
-
保证可用性:自旋超时后降级返回,接口异常率保持0%,符合C端业务用户体验要求;
-
提升可维护性:全链路日志埋点,便于上线后排查锁竞争相关问题(如热点Key竞争频繁)。
缓存重建线程池优化------生产级线程池配置
一、优化思路(核心:为什么要自定义线程池?)
缓存重建是高并发场景下的核心异步操作(逻辑过期缓存过期后,异步重建缓存,避免阻塞用户请求),而线程池是异步任务的核心载体。JDK默认提供的Executors线程池(如newFixedThreadPool)存在严重的上线风险,无法满足生产环境的高并发需求,因此我们需要自定义生产级线程池。
我们的优化思路是:设计"全参数可控、适配业务场景、无上线风险"的线程池,核心围绕5个关键点展开:
-
避免OOM风险:使用有界任务队列,限制任务堆积的最大数量,防止队列无限膨胀占用大量内存;
-
适配业务类型:缓存重建属于IO密集型任务(查库+写缓存,IO等待时间长),线程池参数需适配IO密集型场景,提升任务执行效率;
-
有兜底拒绝策略:任务队列满时,有明确的兜底方案,避免任务丢失,同时保护系统不被压垮;
-
便于问题排查:自定义线程名称,让日志中能快速区分线程归属(如缓存重建线程);
-
可监控:补充任务异常捕获,确保缓存重建失败可感知、可排查。
为什么不能用JDK默认的Executors.newFixedThreadPool?核心原因有5点(也是我们优化的核心动因):
-
无界任务队列:任务突发增多时,队列会无限膨胀,占用大量内存,最终触发OOM(生产环境最致命的问题);
-
无自定义线程名称:默认线程名(pool-1-thread-1)无法区分业务,上线后排查线程相关问题(如线程阻塞、任务异常)困难;
-
无拒绝策略:任务堆积时,线程池会让提交任务的线程无限等待,拖慢整个系统,甚至导致服务不可用;
-
线程数未适配CPU:固定10线程,未根据服务器CPU核心数调整,若CPU核心数为4,10线程会导致CPU上下文切换频繁;若CPU核心数为16,10线程会浪费资源;
-
任务异常吞掉:线程池内任务抛出异常时,默认无任何日志输出,缓存重建失败无法感知,问题隐蔽,难以排查。
补充:IO密集型任务的线程池参数规律------最大线程数=CPU核心数×2,因为IO等待时间长,多设置线程可提高CPU利用率,避免资源浪费。
二、问题场景
前期缓存重建线程池使用JDK默认的Executors.newFixedThreadPool(10),存在上述5个核心问题,若直接上线,可能导致OOM、任务丢失、问题排查困难等严重故障,无法支撑高并发场景下的缓存重建需求。
三、实现方案
基于"全参数可控、适配IO密集型、无上线风险"的思路,我们设计了生产级线程池,具体配置和实现步骤如下:
-
参数适配(核心:适配IO密集型任务和CPU核心数):
-
核心线程数=CPU核心数(Runtime.getRuntime().availableProcessors()):保证基础并发处理能力,避免资源浪费;
-
最大线程数=CPU核心数×2:应对突发的缓存重建任务(如热点Key集中过期),提升任务执行效率;
-
非核心线程存活时间=60秒:任务处理完成后,闲置的非核心线程及时销毁,节省内存和CPU资源;
-
有界任务队列=100:限制任务堆积的最大数量,避免队列无限膨胀,触发OOM。
-
-
自定义线程工厂(核心:便于排查问题):设置线程名称前缀(如cache-rebuild-),让日志中能快速区分缓存重建线程;设置非守护线程,确保缓存重建任务执行完成(守护线程会随JVM退出而终止)。
-
配置兜底拒绝策略(核心:避免任务丢失):使用ThreadPoolExecutor.CallerRunsPolicy,当任务队列满、线程数达到最大值时,由提交任务的线程(如Tomcat线程)执行该任务,既避免任务丢失,又能自动限流(Tomcat线程执行任务时,无法处理新请求),保护系统。
-
补充异常监控(核心:可感知故障):在线程池任务中添加异常捕获,打印详细日志(如缓存重建失败的Key、异常信息),确保缓存重建失败可监控、可排查。
核心代码示例(生产级线程池配置,完全体现优化思路):
java
// 1. 提取线程池核心参数(适配CPU核心数和IO密集型任务,便于配置中心管理)
private static final int CPU_CORES = Runtime.getRuntime().availableProcessors(); // 获取服务器CPU核心数
private static final int CORE_POOL_SIZE = CPU_CORES; // 核心线程数=CPU核心数(基础并发)
private static final int MAX_POOL_SIZE = CPU_CORES * 2; // 最大线程数=CPU核心数×2(IO密集型)
private static final long KEEP_ALIVE_TIME = 60L; // 非核心线程存活时间(60秒,节省资源)
private static final int QUEUE_CAPACITY = 100; // 有界队列容量,避免OOM
private static final String THREAD_NAME_PREFIX = "cache-rebuild-"; // 线程名称前缀,便于排查
// 2. 生产级缓存重建线程池(全参数可控,无上线风险)
private static final ExecutorService CACHE_REBUILD_EXECUTOR = new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_TIME,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(QUEUE_CAPACITY), // 有界队列,避免OOM(核心优化)
new ThreadFactory() { // 自定义线程工厂,便于排查
private final AtomicInteger threadNum = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r, THREAD_NAME_PREFIX + threadNum.getAndIncrement());
thread.setPriority(Thread.NORM_PRIORITY); // 正常优先级,不抢占CPU
thread.setDaemon(false); // 非守护线程,确保任务执行完成
return thread;
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // 兜底拒绝策略,避免任务丢失
);
// 3. 线程池任务异常捕获(可监控,避免异常吞掉)
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R newData = dbFallback.apply(id);
this.setWithLogicalExpire(key, newData, time, unit); // 缓存重建核心逻辑
} catch (Exception e) {
log.error("缓存重建任务失败,key={}", key, e); // 打印详细日志,便于排查
} finally {
unLock(lockKey); // 释放分布式锁,避免锁泄露
}
});
四、优化效果
通过上述生产级线程池配置,彻底解决了默认线程池的所有问题,达到了生产环境的稳定性要求:
-
消除OOM风险:有界队列限制了任务堆积数量,线程池内存占用可控(8核服务器约8~16MB);
-
提升任务执行效率:线程池参数适配IO密集型任务,缓存重建任务执行效率提升30%~50%;
-
无任务丢失:拒绝策略兜底,即使任务队列满,也不会丢失任务,缓存重建成功率100%;
-
便于问题排查:自定义线程名称+异常日志,可快速定位缓存重建相关的线程问题和任务异常;
-
符合生产标准:全参数可控,可根据服务器配置和业务需求动态调整,可直接上线使用。
六、最终优化成果总结
一、核心优化思路回顾
阶段四至阶段六的优化,始终围绕"解决问题→推导思路→落地方案→验证效果"的闭环,三个阶段的核心思路可总结为:
-
服务解耦:核心思路是"高内聚、低耦合,谁使用、谁负责",解决服务职责混乱、耦合过高的问题,为后续扩展奠定基础;
-
分布式锁自旋优化:核心思路是"避免无限阻塞、减少惊群效应、优先保证可用性",解决长尾请求和CPU空转问题,提升接口稳定性;
-
线程池优化:核心思路是"全参数可控、适配业务场景、无上线风险",解决默认线程池的OOM、任务丢失等问题,满足生产级需求。
二、最终压测指标
通过三个阶段的优化,接口性能和稳定性达到生产级标准,具体指标如下(2000线程并发压测):
| 核心指标 | 优化前 | 最终优化结果 | 优化幅度 |
|---|---|---|---|
| 平均响应时间 | 890ms | 625ms | 降低30% |
| 最大响应时间 | 2909ms | 1158ms | 降低78.97% |
| 吞吐量 | 2120 QPS | 3048.5 QPS | 提升43.8% |
| 异常率 | 37.34% | 0.00% | 完全消除 |
三、核心学习启示
-
高并发优化不是"盲目堆代码",而是"先想清楚为什么要优化、优化思路是什么",再落地方案,思路决定方案的合理性;
-
C端业务的核心是"可用性优先",所有优化都要围绕"不报错、不阻塞、用户无感知"展开,比如分布式锁自旋超时降级、线程池拒绝策略兜底;
-
架构设计的核心是"高内聚、低耦合",服务职责边界清晰,才能降低维护成本、支持后续扩展;
-
生产级代码的核心是"可控、可监控、无风险",比如线程池用有界队列、全链路日志埋点,避免出现隐蔽问题;
-
优化是一个闭环过程,发现问题后,推导思路、落地方案,再通过压测验证效果,不断调整,才能达到预期目标。
七、核心踩坑指南
结合本次优化,总结3个核心踩坑点,重点从"思路层面"规避,避免走弯路:
-
踩坑点1:将业务定制化逻辑耦合在基础服务中------核心避坑思路:明确"基础服务负责原子化能力,业务服务负责定制化逻辑",遵循"谁使用、谁负责";
-
踩坑点2:分布式锁自旋用固定休眠时间、无重试次数限制------核心避坑思路:自旋必须用"指数退避+次数限制",优先保证可用性,超时降级;
-
踩坑点3:生产环境使用JDK默认线程池------核心避坑思路:线程池必须自定义,重点关注"有界队列、拒绝策略、线程名称、异常监控",适配业务类型。