🚀 实习成长日记:让 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();
⚠️ 并发避坑指南
- 一定要用线程池,不要每个请求都
new Thread()。 - 设置超时,避免某个任务卡死拖垮整个请求(工具类已内置)。
- 任务之间必须无依赖,有依赖的话只能用
CompletableFuture链式组合。 - 注意线程安全:汇总结果时优先使用
ConcurrentHashMap、AtomicLong等并发工具。 - 超时后别忘了取消:我们工具类在整体超时时会
cancel(true),防止线程一直阻塞。
🛠️ 下次遇到类似场景,直接查这张表
| 症状 | 诊断 | 处方 |
|---|---|---|
| 翻页时每次都卡、数据库负载高 | 缺少缓存 | Redis 缓存中间结果,设置合理过期时间 |
| 聚合多个接口 / 数据源,总耗时是加和 | 串行执行 | 用并发执行,汇总结果 |
| 接口返回慢,但单次查询并不慢 | 串行查多个独立数据源 | 用多线程并行请求,CompletableFuture |
| 缓存 key 设计混乱 | 没有统一规范 | 业务前缀 : MD5(请求参数) |
🌟 实习成长感悟
这三个月,从只会写顺序 for 循环的新手,到能下意识用 Redis 挡掉 80% 重复计算、用并发把 N 个接口耗时压成 1 个,过程真的很爽。
主管帮忙改的这几十行代码,比我自己写一千行 CRUD 都值。现在我把这些可复用的并发、缓存模板存在这里,以后遇到类似需求,直接翻出这篇日记,复制→修改→上线,省时又稳定。
💬 如果你也正在实习,遇到同样的问题,欢迎把这篇文章放进你的“工具库”收藏夹。
每一次踩坑后的总结,都是未来跳得更高的那块跳板。