点评项目深入改造-------日常学习笔记

高并发热门博客接口优化学习文档(阶段四及以后)

前言

解决关联数据未缓存------再次触发连接池耗尽

问题本质(为什么再次出现连接耗尽?)

博客主表缓存生效后,高并发压测再次出现连接池耗尽异常,报错定位到UserMapper.selectBatchIds,核心根因:

缓存只做了一半------博客列表数据被缓存了,但批量填充用户信息的queryBlogUserBatch方法,还是每次都调用userService.listByIds(userIds)直接查数据库,高并发下大量请求同时查用户表,再次占满连接池。

关键认知:高并发接口优化必须"全链路缓存",接口涉及的所有数据库查询(主表、关联表)都必须做缓存,否则关联表的查询仍会成为性能瓶颈,导致之前的优化前功尽弃。

排查与思考过程

  1. 第一步:定位瓶颈------通过日志发现,userService.listByIds(userIds) 频繁执行,大量请求打用户表,导致连接池耗尽;

  2. 第二步:明确需求------需要给"用户批量查询"加缓存,且要解决两个核心难点:

  3. 难点1:用户ID集合是Set(无序、不重复),相同ID集合顺序不同(如{1,2}和{2,1}),会生成不同的缓存Key,导致重复查库;

  4. 难点2:分布式锁粒度要细,不能所有用户批量查询都抢同一个锁,否则会出现锁冲突,导致性能瓶颈;

  5. 第三步:推导解决方案------

  6. 解决难点1:对用户ID进行排序,再拼接成字符串,确保相同ID集合生成唯一的缓存Key(如{1,2}和{2,1}排序后都是"1,2");

  7. 解决难点2:把"排序后的用户ID字符串"作为queryWithLogicalExpireid参数传入,让锁Key = 锁前缀 + 排序后的ID字符串,每个用户集合对应唯一的锁,避免锁冲突。

关键思考:缓存Key的唯一性和分布式锁的细粒度,是高并发缓存设计的核心,必须避免"相同数据不同Key""不同数据同一锁"的问题,否则会引入新的性能瓶颈。

架构职责边界优化------服务解耦

一、优化思路

高并发系统的核心架构要求是"高内聚、低耦合",即每个服务只负责自己核心的职责,不掺杂其他模块的业务逻辑,这样才能降低代码维护成本、支持后续扩展,避免出现"牵一发而动全身"的问题。

结合本次优化场景,我们的核心思路的是:明确"基础服务"与"业务服务"的职责边界------基础服务(如UserService)只提供通用的原子化数据能力,不耦合任何业务定制化逻辑;业务定制化逻辑(如缓存规则)由使用该逻辑的业务服务(如BlogService)负责,遵循"谁使用、谁负责"的原则。

为什么要这么做?核心原因有3点:

  1. 避免职责混乱:UserService的核心职责是"用户数据的CRUD",若耦合Blog模块的缓存逻辑,会导致UserService职责膨胀,后续维护时无法快速定位核心功能;

  2. 支持扩展:若后续Comment、Order等模块也需要查询用户并设置不同的缓存规则,无需修改UserService,只需在对应业务服务中实现,符合"开闭原则"(扩展功能不修改原有代码);

  3. 降低耦合风险:缓存规则属于Blog模块的业务需求,若Blog模块的缓存策略变更(如修改TTL、锁Key),只需修改BlogService,不会影响UserService的正常运行,减少故障传播。

二、问题场景

前期优化中,我们将带缓存的用户批量查询方法(listByIdsWithCache)放在了UserService中,出现了明显的架构耦合问题:该方法是为Blog模块定制的------用于查询博客作者信息并缓存,与UserService的核心职责(用户基础操作)无关,导致UserService既负责用户数据管理,又负责Blog模块的缓存逻辑,职责混乱。

三、实现方案

基于"基础服务纯净、业务逻辑归位"的思路,我们采用轻量易落地的方案,适配中小型项目,具体步骤如下:

  1. 缓存逻辑迁移:将用户批量查询的缓存逻辑(listUsersWithCache)从UserService移至BlogService,作为BlogService的私有方法,仅服务于当前模块的业务需求(查询博客作者信息);

  2. UserService回归核心职责:删除UserService中所有缓存相关代码,仅保留MyBatis-Plus提供的基础listByIds方法,专注于用户基础数据的查询,不掺杂任何业务定制化逻辑;

  3. 解耦核心保障: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
    );
}

四、优化效果

通过上述方案,实现了服务职责的清晰划分,达到了"高内聚、低耦合"的目标:

  1. 维护成本降低:后续修改Blog模块的缓存规则(如调整TTL),仅需修改BlogService,无需改动UserService;

  2. 扩展更灵活:其他模块需要查询用户并添加缓存时,可在自身服务内实现,不影响UserService和BlogService;

  3. 代码可读性提升:每个服务的核心职责明确,开发者可快速定位功能代码,降低排查问题的成本。

分布式锁自旋逻辑优化------解决长尾请求与惊群效应

一、优化思路(核心:为什么要优化自旋逻辑?)

分布式锁的核心作用是"解决高并发下的缓存重建竞争",而自旋逻辑是分布式锁的关键------未获取到锁的线程,通过"短暂休眠+重试"的方式等待锁释放,若自旋逻辑不合理,会导致两个核心问题:长尾请求(部分请求阻塞时间过长)和惊群效应(大量线程同时竞争锁,增加CPU开销)。

结合C端业务的核心需求(可用性优先),我们的优化思路是:

  1. 避免无限阻塞:给自旋设置"最大重试次数",防止线程一直休眠重试,导致请求长时间阻塞(超过3秒上线红线);

  2. 减少惊群效应:采用"指数退避"的休眠策略,让未获取锁的线程按递增的时间休眠,避免所有线程同时唤醒、竞争锁;

  3. 优先保证可用性:自旋超时后,不抛异常,而是降级返回可用数据(如null、旧数据),符合C端业务"数据最终一致性>强一致性"的原则;

  4. 保证锁操作安全:仅当线程成功获取锁时,才执行释放操作,避免误释放其他线程持有的锁,引发并发问题。

为什么要这么做?核心原因有4点:

  1. 无限自旋会导致CPU空转:若线程一直休眠重试,会占用大量CPU资源,拖慢整个系统的吞吐量;

  2. 固定休眠时间会引发惊群效应:所有线程同时休眠50ms后重试,会导致大量线程同时竞争锁,瞬间增加CPU和Redis的压力;

  3. 粗暴抛异常会影响用户体验:C端用户更关注"请求能否响应",而非"数据是否绝对实时",抛异常会导致用户看到报错,降低体验;

  4. 误释放锁会引发并发问题:若线程未获取到锁,却执行释放操作,可能释放其他线程持有的锁,导致缓存重建出现并发安全问题(如重复查库)。

二、问题场景

优化前,分布式锁的自旋逻辑存在明显缺陷:无重试次数限制、固定50ms休眠时间,导致压测中出现接口最大响应时间2909ms(长尾请求),CPU空转严重,部分线程因无限自旋导致阻塞,影响系统稳定性。

三、实现方案

基于"指数退避+次数限制+超时降级+安全锁释放"的思路,我们设计了可落地的优化方案,具体步骤如下:

  1. 设置最大重试次数和基础休眠时间:

    1. 最大重试次数设为8次,结合指数退避策略,总休眠时间约2.5秒(10+20+40+80+160+320+640+1280ms),不超过3秒上线红线;

    2. 基础休眠时间设为10ms,后续每次重试的休眠时间按2的幂次递增(指数退避),避免惊群效应。

  2. 实现指数退避自旋逻辑:未获取到锁的线程,按"10→20→40→80ms"的递增时间休眠,每次休眠后重试,直至达到最大重试次数。

  3. 自旋超时降级:若重试8次仍未获取到锁,不抛异常,返回null(或旧数据),优先保证请求不失败,后续由其他线程完成缓存重建。

  4. 安全锁释放:添加锁获取标记(isLockSuccess),仅当线程成功获取锁时,才执行释放锁操作,避免误释放。

  5. 异常友好处理:捕获InterruptedException(线程休眠被中断),恢复线程中断标记,降级返回null,符合Java线程规范,避免业务异常。

  6. 全链路日志埋点:添加锁获取、释放、重试、降级的日志,便于上线后排查锁竞争问题(如重试频繁、超时过多)。

核心代码示例(优化后的自旋逻辑,体现思路落地):

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);
    }
}

四、优化效果

通过上述方案,彻底解决了自旋逻辑带来的长尾请求和惊群效应问题,达到了预期目标:

  1. 消除长尾请求:接口最大响应时间从2909ms降至1158ms,控制在1.2秒以内,远低于3秒上线红线;

  2. 降低CPU开销:指数退避策略减少了惊群效应,锁竞争的CPU使用率降低30%~50%,系统吞吐量更稳定;

  3. 保证可用性:自旋超时后降级返回,接口异常率保持0%,符合C端业务用户体验要求;

  4. 提升可维护性:全链路日志埋点,便于上线后排查锁竞争相关问题(如热点Key竞争频繁)。

缓存重建线程池优化------生产级线程池配置

一、优化思路(核心:为什么要自定义线程池?)

缓存重建是高并发场景下的核心异步操作(逻辑过期缓存过期后,异步重建缓存,避免阻塞用户请求),而线程池是异步任务的核心载体。JDK默认提供的Executors线程池(如newFixedThreadPool)存在严重的上线风险,无法满足生产环境的高并发需求,因此我们需要自定义生产级线程池。

我们的优化思路是:设计"全参数可控、适配业务场景、无上线风险"的线程池,核心围绕5个关键点展开:

  1. 避免OOM风险:使用有界任务队列,限制任务堆积的最大数量,防止队列无限膨胀占用大量内存;

  2. 适配业务类型:缓存重建属于IO密集型任务(查库+写缓存,IO等待时间长),线程池参数需适配IO密集型场景,提升任务执行效率;

  3. 有兜底拒绝策略:任务队列满时,有明确的兜底方案,避免任务丢失,同时保护系统不被压垮;

  4. 便于问题排查:自定义线程名称,让日志中能快速区分线程归属(如缓存重建线程);

  5. 可监控:补充任务异常捕获,确保缓存重建失败可感知、可排查。

为什么不能用JDK默认的Executors.newFixedThreadPool?核心原因有5点(也是我们优化的核心动因):

  1. 无界任务队列:任务突发增多时,队列会无限膨胀,占用大量内存,最终触发OOM(生产环境最致命的问题);

  2. 无自定义线程名称:默认线程名(pool-1-thread-1)无法区分业务,上线后排查线程相关问题(如线程阻塞、任务异常)困难;

  3. 无拒绝策略:任务堆积时,线程池会让提交任务的线程无限等待,拖慢整个系统,甚至导致服务不可用;

  4. 线程数未适配CPU:固定10线程,未根据服务器CPU核心数调整,若CPU核心数为4,10线程会导致CPU上下文切换频繁;若CPU核心数为16,10线程会浪费资源;

  5. 任务异常吞掉:线程池内任务抛出异常时,默认无任何日志输出,缓存重建失败无法感知,问题隐蔽,难以排查。

补充:IO密集型任务的线程池参数规律------最大线程数=CPU核心数×2,因为IO等待时间长,多设置线程可提高CPU利用率,避免资源浪费。

二、问题场景

前期缓存重建线程池使用JDK默认的Executors.newFixedThreadPool(10),存在上述5个核心问题,若直接上线,可能导致OOM、任务丢失、问题排查困难等严重故障,无法支撑高并发场景下的缓存重建需求。

三、实现方案

基于"全参数可控、适配IO密集型、无上线风险"的思路,我们设计了生产级线程池,具体配置和实现步骤如下:

  1. 参数适配(核心:适配IO密集型任务和CPU核心数):

    1. 核心线程数=CPU核心数(Runtime.getRuntime().availableProcessors()):保证基础并发处理能力,避免资源浪费;

    2. 最大线程数=CPU核心数×2:应对突发的缓存重建任务(如热点Key集中过期),提升任务执行效率;

    3. 非核心线程存活时间=60秒:任务处理完成后,闲置的非核心线程及时销毁,节省内存和CPU资源;

    4. 有界任务队列=100:限制任务堆积的最大数量,避免队列无限膨胀,触发OOM。

  2. 自定义线程工厂(核心:便于排查问题):设置线程名称前缀(如cache-rebuild-),让日志中能快速区分缓存重建线程;设置非守护线程,确保缓存重建任务执行完成(守护线程会随JVM退出而终止)。

  3. 配置兜底拒绝策略(核心:避免任务丢失):使用ThreadPoolExecutor.CallerRunsPolicy,当任务队列满、线程数达到最大值时,由提交任务的线程(如Tomcat线程)执行该任务,既避免任务丢失,又能自动限流(Tomcat线程执行任务时,无法处理新请求),保护系统。

  4. 补充异常监控(核心:可感知故障):在线程池任务中添加异常捕获,打印详细日志(如缓存重建失败的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); // 释放分布式锁,避免锁泄露
    }
});

四、优化效果

通过上述生产级线程池配置,彻底解决了默认线程池的所有问题,达到了生产环境的稳定性要求:

  1. 消除OOM风险:有界队列限制了任务堆积数量,线程池内存占用可控(8核服务器约8~16MB);

  2. 提升任务执行效率:线程池参数适配IO密集型任务,缓存重建任务执行效率提升30%~50%;

  3. 无任务丢失:拒绝策略兜底,即使任务队列满,也不会丢失任务,缓存重建成功率100%;

  4. 便于问题排查:自定义线程名称+异常日志,可快速定位缓存重建相关的线程问题和任务异常;

  5. 符合生产标准:全参数可控,可根据服务器配置和业务需求动态调整,可直接上线使用。

六、最终优化成果总结

一、核心优化思路回顾

阶段四至阶段六的优化,始终围绕"解决问题→推导思路→落地方案→验证效果"的闭环,三个阶段的核心思路可总结为:

  1. 服务解耦:核心思路是"高内聚、低耦合,谁使用、谁负责",解决服务职责混乱、耦合过高的问题,为后续扩展奠定基础;

  2. 分布式锁自旋优化:核心思路是"避免无限阻塞、减少惊群效应、优先保证可用性",解决长尾请求和CPU空转问题,提升接口稳定性;

  3. 线程池优化:核心思路是"全参数可控、适配业务场景、无上线风险",解决默认线程池的OOM、任务丢失等问题,满足生产级需求。

二、最终压测指标

通过三个阶段的优化,接口性能和稳定性达到生产级标准,具体指标如下(2000线程并发压测):

核心指标 优化前 最终优化结果 优化幅度
平均响应时间 890ms 625ms 降低30%
最大响应时间 2909ms 1158ms 降低78.97%
吞吐量 2120 QPS 3048.5 QPS 提升43.8%
异常率 37.34% 0.00% 完全消除

三、核心学习启示

  1. 高并发优化不是"盲目堆代码",而是"先想清楚为什么要优化、优化思路是什么",再落地方案,思路决定方案的合理性;

  2. C端业务的核心是"可用性优先",所有优化都要围绕"不报错、不阻塞、用户无感知"展开,比如分布式锁自旋超时降级、线程池拒绝策略兜底;

  3. 架构设计的核心是"高内聚、低耦合",服务职责边界清晰,才能降低维护成本、支持后续扩展;

  4. 生产级代码的核心是"可控、可监控、无风险",比如线程池用有界队列、全链路日志埋点,避免出现隐蔽问题;

  5. 优化是一个闭环过程,发现问题后,推导思路、落地方案,再通过压测验证效果,不断调整,才能达到预期目标。

七、核心踩坑指南

结合本次优化,总结3个核心踩坑点,重点从"思路层面"规避,避免走弯路:

  1. 踩坑点1:将业务定制化逻辑耦合在基础服务中------核心避坑思路:明确"基础服务负责原子化能力,业务服务负责定制化逻辑",遵循"谁使用、谁负责";

  2. 踩坑点2:分布式锁自旋用固定休眠时间、无重试次数限制------核心避坑思路:自旋必须用"指数退避+次数限制",优先保证可用性,超时降级;

  3. 踩坑点3:生产环境使用JDK默认线程池------核心避坑思路:线程池必须自定义,重点关注"有界队列、拒绝策略、线程名称、异常监控",适配业务类型。

相关推荐
Ivanqhz2 小时前
寄存器分配的核心函数 allocate
java·开发语言·后端·python·rust
爱吃烤鸡翅的酸菜鱼2 小时前
Spring Cloud Eureka 服务注册与发现实战详解:从原理到高可用集群搭建
java·spring·spring cloud·eureka
野犬寒鸦2 小时前
JVM垃圾回收机制深度解析(G1篇)(垃圾回收过程及专业名词详解)(补充)
java·服务器·开发语言·jvm·后端·面试
白宇横流学长2 小时前
基于SpringBoot实现的信息技术知识赛系统设计与实现【源码+文档】
java·spring boot·后端
历程里程碑2 小时前
44. TCP -23Linux聊天室实现命令符功能
java·linux·开发语言·数据结构·c++·排序算法·tcp
丶小鱼丶2 小时前
数据结构和算法之【二叉树】
java·数据结构·算法
SimonKing2 小时前
OpenClaw,再见!
java·后端·程序员
softbangong2 小时前
829-批量提取各子文件夹下文件到一级目录
java·服务器·前端·自动化工具·批量文件处理·文件提取工具·文件夹整理
测试_AI_一辰2 小时前
Agent & RAG 测试工程笔记 13:RAG检索层原理拆解:从“看不懂”到手算召回过程
人工智能·笔记·功能测试·算法·ai·ai编程