【代码锦囊】多网点分页查询慢?Redis + 并发通用模板速查

2 阅读5分钟

🚀 实习成长日记:让 Redis 与并发成为你的“肌肉记忆”

背景
实习第三个月,独立负责一个多网点协同问题件查询功能。
初始版本所有请求都是串行阻塞 + 实时计算,页面翻页慢得像在等红绿灯。
主管 review 后,十几行改动就让接口耗时下降了 70%。
这两个魔法关键词就是:Redis 缓存并发查询

写这篇日记,就是为了把这两招刻进 DNA 里 —— 以后遇到类似场景,复制模板、小改一下,直接就能用。


🧠 什么时候该想起 Redis?

场景判断口诀

跨请求的重复计算结果,短时间不需要 100% 实时,那就扔进 Redis。

在分页查询里,总条数(total) 就是典型:

  • 用户翻页时,每换一页都要知道 total。
  • 如果每次翻页都重新 count 一次所有网点,纯属浪费。
  • 这个 total 不需要毫秒级实时,缓存 10 分钟完全可以接受。

📦 可复用的 Redis 缓存模板

// 1. 用请求参数的 MD5 作为缓存键(确保唯一性)
String cacheKey = "CACHE_QUERY_PAGE:" + req.getMd5();

// 2. 翻第一页时,计算并放入缓存
if (pageNum == 1) {
    // ... 计算 totalCount 和 totalCounts
    RedisUtils.set(cacheKey + "totalCount", totalCount, Duration.ofMinutes(10));
    RedisUtils.set(cacheKey + "totalCounts", totalCounts, Duration.ofMinutes(10));
} else {
    // 3. 后续翻页直接从 Redis 取,不再重复计算
    totalCounts = RedisUtils.get(cacheKey + "totalCounts");
    totalCount   = RedisUtils.get(cacheKey + "totalCount");
}

🔑 关键点

  • Key 的设计业务前缀 + 请求参数哈希,避免冲突。
  • 过期时间:根据业务容忍度设置,像总数统计 10 分钟完全够用。
  • 缓存什么:不是缓存最终列表,而是缓存计算成本高但不变性较强的中间结果

⚡ 什么时候该用并发?

场景判断口诀

有多个互不依赖的 I/O 任务,顺序执行太慢,那就并发执行。

例子:多网点分页需要先拿到每个网点的 count
传统写法是一个个网点调接口,总耗时 = N × 单次耗时。
主管改成并发后,耗时直接降到 max(单次耗时)

⚙️ 通用并发查询模板(直接复制到项目里)

经过多轮打磨,下面这个工具类已经具备生产级水准:泛型安全、任务级超时、超时自动取消、异常隔离、优雅关闭——你完全可以把它放进项目的 common 包里开箱即用。

@Slf4j
@Component
public class ConcurrentResultCollector implements DisposableBean {

    private static final AtomicInteger THREAD_COUNTER = new AtomicInteger(0);

    // IO 密集型线程池:核心线程 = CPU核数*2,最大 = CPU核数*4
    private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
            Runtime.getRuntime().availableProcessors() * 2,
            Runtime.getRuntime().availableProcessors() * 4,
            60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(5000),
            r -> new Thread(r, "concurrent-task-" + THREAD_COUNTER.incrementAndGet()),
            new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝时由调用线程执行,不丢任务
    );

    static {
        EXECUTOR.allowCoreThreadTimeOut(true);  // 核心线程空闲超时回收
    }

    private static final long DEFAULT_TIMEOUT = 60;

    /**
     * 并发执行多个无依赖任务,按 key 返回结果(默认超时 60 秒)
     *
     * @param tasks key -> 任务(Supplier)
     * @param <V>   返回值类型
     * @return 成功完成且非 null 的结果 Map
     */
    public static <V> Map<String, V> collect(Map<String, Supplier<V>> tasks) {
        return collect(tasks, DEFAULT_TIMEOUT, TimeUnit.SECONDS);
    }

    /**
     * 带自定义超时的并发收集
     *
     * @param tasks   任务集合
     * @param timeout 单任务超时时间
     * @param unit    时间单位
     * @param <V>     返回值类型
     * @return 任务结果 Map(超时/异常的任务不会出现)
     */
    public static <V> Map<String, V> collect(Map<String, Supplier<V>> tasks, long timeout, TimeUnit unit) {
        Map<String, V> resultMap = new ConcurrentHashMap<>(tasks.size());
        if (tasks == null || tasks.isEmpty()) return resultMap;

        List<CompletableFuture<?>> futures = tasks.entrySet().stream()
                .map(entry -> CompletableFuture
                        .supplyAsync(entry.getValue(), EXECUTOR)     // 异步执行
                        .orTimeout(timeout, unit)                   // 任务级超时
                        .exceptionally(ex -> {                      // 异常/超时隔离
                            if (ex instanceof TimeoutException) {
                                log.warn("任务超时 key:{}", entry.getKey());
                            } else {
                                log.error("任务执行异常 key:{}", entry.getKey(), ex);
                            }
                            return null;
                        })
                        .thenAccept(value -> {                      // 收集非 null 结果
                            if (value != null) resultMap.put(entry.getKey(), value);
                        })
                ).toList();

        try {
            // 整体兜底超时(设为单任务超时的 2 倍)
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
                    .get(timeout * 2, unit);
        } catch (TimeoutException e) {
            log.warn("整体任务超时,强制取消所有未完成任务");
            futures.forEach(f -> f.cancel(true));   // 关键:释放线程,避免资源泄漏
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("任务被中断", e);
        } catch (Exception e) {
            log.error("并发任务执行异常", e);
        }

        return resultMap;
    }

    // Spring 容器关闭时自动优雅停止线程池
    @Override
    public void destroy() {
        EXECUTOR.shutdown();
        try {
            if (!EXECUTOR.awaitTermination(60, TimeUnit.SECONDS)) {
                EXECUTOR.shutdownNow();
            }
        } catch (InterruptedException e) {
            EXECUTOR.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

🎯 主管代码里的实际用法(抽象后可直接照抄)

// 1. 构建任务:每个网点一个 Supplier
Map<String, Supplier<Long>> tasks = new LinkedHashMap<>();
for (String wdbm : wdList) {
    tasks.put(wdbm, () -> {
        ReplyListReq countReq = BeanUtil.copyProperties(req, ReplyListReq.class);
        countReq.setWdbm(wdbm);
        countReq.setRows(0);   // 只查 count,不查数据
        YunDaResp.problemModel res = dataFunction.apply(countReq);
        return res.getData() != null ? res.getData().getTotal() : 0L;
    });
}

// 2. 并发执行 & 收集(工具类已经封装好超时控制和异常隔离)
Map<String, Long> results = ConcurrentResultCollector.collect(tasks, 30, TimeUnit.SECONDS);

// 3. 汇总 totalCount(AtomicLong 保证线程安全)
AtomicLong totalCount = new AtomicLong(0);
results.values().forEach(totalCount::addAndGet);

// 或者更函数式
long total = results.values().stream().mapToLong(Long::longValue).sum();

⚠️ 并发避坑指南

  1. 一定要用线程池,不要每个请求都 new Thread()
  2. 设置超时,避免某个任务卡死拖垮整个请求(工具类已内置)。
  3. 任务之间必须无依赖,有依赖的话只能用 CompletableFuture 链式组合。
  4. 注意线程安全:汇总结果时优先使用 ConcurrentHashMapAtomicLong 等并发工具。
  5. 超时后别忘了取消:我们工具类在整体超时时会 cancel(true),防止线程一直阻塞。

🛠️ 下次遇到类似场景,直接查这张表

症状诊断处方
翻页时每次都卡、数据库负载高缺少缓存Redis 缓存中间结果,设置合理过期时间
聚合多个接口 / 数据源,总耗时是加和串行执行用并发执行,汇总结果
接口返回慢,但单次查询并不慢串行查多个独立数据源用多线程并行请求,CompletableFuture
缓存 key 设计混乱没有统一规范业务前缀 : MD5(请求参数)

🌟 实习成长感悟

这三个月,从只会写顺序 for 循环的新手,到能下意识用 Redis 挡掉 80% 重复计算、用并发把 N 个接口耗时压成 1 个,过程真的很爽。

主管帮忙改的这几十行代码,比我自己写一千行 CRUD 都值。现在我把这些可复用的并发、缓存模板存在这里,以后遇到类似需求,直接翻出这篇日记,复制→修改→上线,省时又稳定。

💬 如果你也正在实习,遇到同样的问题,欢迎把这篇文章放进你的“工具库”收藏夹。
每一次踩坑后的总结,都是未来跳得更高的那块跳板。