欢迎光临
我们一直在努力

Day 2 · Java 后端工程代码审查 — 深入审查 Issue 与解决方案教学文

配套要求:Java 17 / Spring Boot 3 / MySQL 8 / Redis Cluster / RabbitMQ;全部代码示例力求可编译、可运行。


今日审查主线速览

一句话主线:这四道题表面上分别是缓存、安全、MQ、线程池,实际上都在回答同一个工程问题——“系统的边界在哪里?”

缓存要守住一致性边界(Cache Aside 的删除时序与失败处理);文件下载要守住信任边界(客户端输入永远不可信);MQ 要守住投递语义边界(at-least-once 是默认现实,幂等是消费者的第一原则);线程池要守住资源边界(有界 = 背压,线程数由下游决定而非由流量决定)。

代码本身能跑,只代表通过了最低门槛。真正的 Code Review 要逐级追问:正常情况能跑 → 异常情况会怎样 → 并发情况下会怎样 → 依赖故障会怎样 → 流量扩大 100 倍会怎样 → 失败以后能不能恢复。 这四道题就是这六级追问的四个具体切片。

题号主题核心问题风险等级
题1 用户资料缓存 Cache Aside 写后删缓存非原子(commit 成功 delete 失败)、A miss/B update 脏写回、热点 Key 击穿、24h TTL 不合理、无空值缓存 P1(一致性破坏 + 线上已发生击穿)
题2 文件下载接口 客户端直传路径 → 路径穿越任意文件读取(/etc/passwd)、Symbolic Link 绕过、无文件归属校验越权下载、Content-Disposition 头注入、大文件直接经应用传输 P0(任意文件读取 + 越权 = 高危漏洞)
题3 RabbitMQ 消费者 at-least-once 下重复消费积分 +200;开发提出的 catch 吞异常方案导致消息静默丢失;无幂等表、无 DLQ、无限 requeue P0(资金/权益类数据错误 + 消息丢失)
题4 异步任务线程池 newCachedThreadPool 无界线程失控(CPU↑→内存↑→Full GC→JVM 无响应);固定 1000 线程是错误解法;无超时/重试/Bulkhead/Job 状态 P0(OOM 级资源失控,线上已宕机)

题 1:用户资料缓存(Cache Aside 一致性 / 热点 Key 击穿 / 穿透·击穿·雪崩)

🚩 审查 Issue(结论先行)

问题位置风险等级问题类型触发条件可能后果
updateNickname():userRepository.save() 之后直接 redisTemplate.delete()(L498-508) P1(业务可见、有持续不一致窗口,但不丢数据、不涉及资金) 缓存一致性(Cache Aside 时序/原子性缺陷) ① DB commit 成功但 Redis delete 失败;② 线程 A cache miss 读旧值、线程 B 更新、A 把旧值写回缓存 用户修改昵称后偶尔仍看到旧昵称,最长持续到 TTL(24h)过期——线上现象 1 的直接原因
getProfile():set(key, profile, Duration.ofHours(24))(L492-493) P1(明星用户场景线上已真实发生) 热点 Key 击穿(thundering herd) 明星用户 Key 恰好到期的一瞬间,大量请求同时 miss 大量请求同时打到 MySQL,DB 瞬时压力尖峰,可能拖垮用户中心——线上现象 2 的直接原因
getProfile():userId 不存在时 orElseThrow 抛异常,未缓存空值(L487-489) P2(需攻击者/脏数据构造大量不存在 ID 才放大) 缓存穿透 恶意/异常请求携带大量不存在的 userId 每次请求都穿透到 DB;配合多实例(8 个)并发放大,可成为 DB 拒绝服务入口
所有 Key 统一 24h TTL、无随机抖动 P2(需大量 Key 同一时刻写入才触发) 缓存雪崩 某批 Key 在相近时间写入、相近时间到期,且回源无互斥 到期瞬间全量回源,DB 连接池被打满
updateNickname() 的 delete 在 @Transactional 方法体内、事务提交之前执行 P2(窗口小,但逻辑次序错误) 事务与缓存操作次序 DB 更新后事务回滚 缓存被删但 DB 未变 → 只是损失一次命中,无害;但若依赖"删除成功"判断提交,则时序完全错乱——必须养成"提交后删"的习惯
RedisTemplate<String, Object> 默认使用 JDK 序列化(JdkSerializationRedisSerializer) P3(功能可用,但隐患多) 序列化设计缺陷 UserProfile 未实现 Serializable、或升级字段后反序列化失败 运行时 ClassCastException / SerializationException;且 JDK 序列化体积大、不可读、有反序列化安全风险(生产应换 JSON)

结论先行:这是一套"能跑但边界模糊"的经典 Cache Aside 实现——它假设了"DB 提交和 Redis 删除必然同时成功、且不会有并发读旧写回",而现实是两个系统之间不存在分布式事务。必须围绕"删除失败"与"并发写回"两条失败路径重新设计。

📖 深入讲解(为什么有问题)

1. 更新数据库 → 删除缓存,是否一定安全?

不一定。 这是 Cache Aside(旁路缓存)模式的"阿喀琉斯之踵"。Cache Aside 的写路径规则是"先更新 DB,再删除缓存"(Write-Around 思路:让缓存下次读取时自然重建,而不是去更新缓存)。这个规则本身是对的——删除缓存比更新缓存更不容易引入不一致(更新缓存需要把 DB 数据完整序列化进缓存,任何字段遗漏都会造成脏数据;删除则简单粗暴)。但"删除"这一步和"DB commit"发生在两个不同的系统里,没有事务边界,所以存在两个经典失败点:

  • 失败点 1:DB commit 成功,Redis delete 失败。 缓存里躺着旧数据,新数据永远进不来,直到 TTL 到期。
  • 失败点 2:并发时序错乱(A miss / B update)。 见下文第 3 点。

为什么"更新缓存"(Write-Through)反而更危险?因为 DB 与缓存是两套存储,更新缓存成功、DB 失败 → 缓存是新的、DB 是旧的,且没有 TTL 兜底,错误可能永久存在;而删除缓存后,最坏情况是缓存 miss 回源,错误窗口被 TTL 截断。所以业界共识:写路径用"删"不用"改",问题从"如何保证双写一致"降级为"如何保证删除可靠"。

2. DB commit 成功、Redis delete 失败,会怎样?

后果链:commit 成功 → delete 失败 → 缓存里保留旧昵称 → 后续所有读都命中旧缓存 → 用户持续看到旧昵称 → 直到 24h TTL 到期才自动恢复 → 不一致窗口最长 24 小时。

为什么 delete 会失败?Redis Cluster 下可能的原因:网络抖动、Redis 节点故障、连接池耗尽、主从切换期间请求超时。而在本例中更隐蔽的一点是:redisTemplate.delete() 抛出异常时,@Transactional 方法会把异常传播出去导致事务回滚——也就是说 delete 失败不但没删掉缓存,还让已经成功的 DB 更新也回滚了(这倒是"歪打正着"地避免了一致性破坏,但用户的操作直接报错,可用性受损)。这是"缓存操作和 DB 事务耦合"的典型反面教材:缓存删除不应该影响业务事务的成败。

3. 两个线程并发:A cache miss / B update,最终结果?

时间线:

线程 A(读) 线程 B(写)
1. GET cache -> miss
2. SELECT user(读到旧昵称)
3. UPDATE user SET nickname=新昵称
4. DELETE cache
5. SET cache = 旧昵称 ← 脏数据写回!

最终结果:缓存里是旧昵称,DB 里是新昵称,缓存与 DB 不一致,且这个脏数据没有"下一次删除"来纠正——B 已经删过缓存了,A 是在 B 删除之后才写回的。这个脏值会一直躺到 TTL 到期。注意:即使 B 用的是"更新缓存"也一样脏,因为 A 拿到的是删除/更新之前的旧快照。只要"读-写回"不是原子的,"最后写者胜"就会产生脏数据。

业界对策(后面候选方案逐一对比):

  • delayed double delete(延迟双删):B 在删除后延迟 1s 再删一次,把 A 写回的窗口"事后擦掉"——治标,但窗口仍然存在(若 A 的写回发生在第二次删除之后呢?仍有理论窗口,只是概率极低)。
  • 读时加锁回源(mutex/single-flight):让"读 DB + 写缓存"被同一把锁保护,B 的删除与 A 的回源串行化——从根上消除脏写回(推荐,见解决方案)。
  • CDC / MQ 失效:把"删缓存"变成异步可靠事件,配合重试,从可靠性上兜底。
4. 24 小时 TTL 合理吗?

不合理,双层不合理。

  • 对一致性而言:TTL 是不一致的"自愈时间"。24h 意味着一旦 delete 失败或脏写回发生,用户最长要等一天才能看到正确数据。用户资料这种"展示型数据",业界一般把 TTL 压到分钟级(如 30 分钟 + 抖动),让最坏不一致窗口可控。
  • 对击穿而言:TTL 越长,Key 到期瞬间累积的"过期前热度"越高,击穿冲击越大。而且所有 Key 在同一时刻(写入时)起算 24h,会导致同一批写入的 Key 在同一时刻集中过期——这正是雪崩的温床。
  • TTL 的真正职责是"防缓存永不失效"(兜底),而不是"承担一致性修复"。一致性要由删除策略负责,TTL 只做最后防线。
5. 热点 Key 失效会发生什么?(击穿)

明星用户 = 单 Key 超高 QPS。该 Key 到期瞬间,所有读请求同时 miss、同时回源 MySQL。假设该 Key 平时 5000 QPS 全部命中缓存,到期后这 5000 QPS 在一瞬间全部变成 DB 查询——即使 MySQL 单次查询只要 1ms,5000 并发也远超连接池上限(通常几十到几百),导致:连接池排队 → 请求超时 → 雪球式放大。这就是线上现象 2。注意击穿(单个热点 Key 过期)与雪崩(大量 Key 同时过期)的区别:一个是"点"的尖峰,一个是"面"的洪峰。

6. 缓存穿透 / 击穿 / 雪崩,分别有没有风险?
现象定义本代码有没有风险原因
穿透 查询根本不存在的 Key,缓存永远 miss,每次都打 DB ✅ 有(P2) orElseThrow 直接抛异常,没有缓存空值;恶意 userId(如 -1、随机大数)可构造无限穿透
击穿 单个热点 Key 过期瞬间,并发全部回源 ✅ 有(P1,线上已发生) 无互斥回源,8 个实例 × 明星用户 = 8 路并发打到 DB
雪崩 大量 Key 同时过期 / Redis 整体宕机 ✅ 有(P2) 统一 24h TTL 无抖动;Redis Cluster 部分节点故障时,命中该节点的 Key 集体 miss

三者对策不同:穿透 → 空值缓存(短 TTL)+ 参数校验 + 布隆过滤器;击穿 → mutex / single-flight 单飞回源 + logical expire;雪崩 → 随机 TTL + Redis 高可用(Cluster/哨兵)+ 多级缓存(本地 Caffeine 兜底)。

7. 候选方案对比(逐项回答"是否需要")
方案原理优点缺点本场景是否需要
mutex(互斥回源) 用分布式锁(SET key NX EX)保证同一 Key 只有一个线程回源,其余线程等待或短暂自旋后读缓存 直接消灭"脏写回"的并发根源;实现简单(几十行) 进程内锁无法跨实例;锁需要处理过期(锁的 TTL 必须 > 回源耗时,否则锁失效形同虚设);等待线程占用 ✅ 需要(击穿 + 脏写回的双重解药)
single-flight(单飞/合并请求) 合并同一 Key 的并发请求,只执行一次回源,大家共享结果(如 Caffeine get(key, loader)、Guava Striped、自实现 ConcurrentHashMap<Key, CompletableFuture>) 进程内零锁开销、天然合并;比分布式锁更轻 默认只对单实例有效(8 实例下各合并各的,仍可能有 8 路回源);多实例需配合分布式锁或集中式网关 ✅ 需要(与 mutex 二选一或组合:进程内 single-flight + 跨实例分布式锁)
logical expire(逻辑过期) 缓存物理永不过期,value 里额外存 expireAt;读时发现逻辑过期 → 尝试获取锁回源重建,先返回旧值给调用方(stale-while-revalidate) 彻底消灭击穿(过期瞬间用户无感知,后台静默刷新);延迟极低 每个 Key 多存一个时间字段;数据"永远"在缓存里,一致性全靠删除/重建维护;需要处理重建失败(旧值继续服务,但要告警) ✅ 需要(明星用户热点场景的最终形态,可作为进阶优化;普通用户可用短 TTL 代替)
random TTL(随机 TTL) TTL = 基准值 + 随机抖动(如 30min ± 5min) 打散批量 Key 的到期时间,防雪崩,几乎零成本 对"单热点 Key"无效(击穿是单 Key 问题) ✅ 需要(防雪崩;成本极低,必做)
delayed double delete(延迟双删) 删除缓存后,延迟 1s(大于"读+写回"窗口)再删一次 能擦掉 A 写回窗口内的脏数据;实现简单 不是严格正确:若 A 的写回发生在第二次删除之后仍有理论窗口;延迟多久无标准答案(经验 500ms~1s,需按"DB 查询 + 序列化 + 网络"最坏耗时估算);引入额外异步任务 ✅ 需要(作为 delete 失败的兜底与脏写回的补偿,与 mutex 组合使用)
Binlog / CDC(Canal) 监听 MySQL binlog,捕获 UPDATE,异步发"删缓存"事件 与业务代码完全解耦;不依赖业务主动删;天然覆盖所有写路径(包括 DBA 直改、定时任务改库) 引入 Canal + 运维组件;binlog 消费延迟(毫秒~秒级);需要处理主从复制延迟(binlog 是主库的) ⚠️ 可选(大厂标准做法;中小团队用"业务删 + MQ 补偿"足够)
MQ invalidation delete 失败时把 key 发到 MQ,由消费者重试删除;或写路径统一"发失效消息" 删除操作异步化、可靠化、可重试;失败不阻塞业务 多一跳 MQ;消费者删除失败仍需最终兜底(重试上限 + 告警 + 人工) ✅ 需要(作为删除失败的补偿通道;比"同步重试 N 次"更工程化)
8. 最终一致还是强一致?

用户资料(昵称、头像等展示型数据)应当允许"秒级最终一致",不应追求强一致。 理由:

  • 为什么不能强一致?强一致 = 读必须读到最新值 = 读路径要校验/绕过缓存 = 每个读都打 DB 或做分布式事务,缓存存在的意义就没了。
  • 为什么最终一致可接受?昵称变更对用户是"低敏操作",容忍 1~5 秒的展示延迟,产品上无感知;而它换来的读吞吐(缓存命中率 99%+)是刚需。
  • 工程红线:"最终"要有界——必须通过"删除重试 + 短 TTL + 延迟双删"把不一致窗口压到秒级,而不是放任 24h。

对比:如果是余额、库存、支付状态这类资金/强约束数据,结论完全不同——要么不缓存,要么缓存只做"读展示 + 严格失效 + 事务内对账",且必须允许读库兜底。先判断业务性质,再决定一致性级别。

🛠 解决方案

候选方案 → 推荐组合拳:

推荐方案 = Cache Aside 改进版(四件套组合):

  • 写路径:@Transactional 内只更新 DB;用 TransactionSynchronization.afterCommit() 在事务提交成功后再删缓存(保证"先 commit 后 delete"的次序);删除失败走本地重试(3 次)→ 仍失败发 MQ 补偿。
  • 读路径:single-flight + 分布式锁双重单飞回源(进程内合并 + 跨实例互斥);回源结果写缓存。
  • TTL:基础 30 分钟 + 随机抖动 ±5 分钟(防雪崩);空值缓存 5 分钟(防穿透);热点用户(配置/命中计数识别)改用 logical expire(可选进阶)。
  • 兜底:延迟双删(第二次延迟 1s,补偿并发脏写回);删除失败进入 MQ 由消费者重试。
  • Trade-off 说明:为什么不用 CDC 一步到位?——引入 Canal 增加运维复杂度,对"用户资料"这个量级是过度设计;"业务主动删 + MQ 补偿"已能覆盖 99% 的失败路径,且代码直观可测。为什么 mutex 锁 TTL 取 3s?——锁的生命周期必须覆盖"DB 查询 + 序列化 + 写缓存"的最坏耗时(本场景 <100ms,3s 充裕);锁过期早于回源完成会导致第二个线程也拿到锁(锁失效),因此锁 TTL 宁大勿小,回源完成后主动释放。

    // ============ 1. 配置:RedisTemplate 改用 JSON 序列化(替代 JDK 序列化) ============
    package com.example.user.config;

    import com.fasterxml.jackson.databind.ObjectMapper;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.data.redis.connection.RedisConnectionFactory;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
    import org.springframework.data.redis.serializer.StringRedisSerializer;

    @Configuration
    public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
    RedisConnectionFactory connectionFactory,
    ObjectMapper objectMapper) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(connectionFactory);
    // Key 用 String,Value 用 JSON,避免 JDK 序列化的体积大/不可读/反序列化风险
    StringRedisSerializer stringSerializer = new StringRedisSerializer();
    GenericJackson2JsonRedisSerializer jsonSerializer =
    new GenericJackson2JsonRedisSerializer(objectMapper);
    template.setKeySerializer(stringSerializer);
    template.setHashKeySerializer(stringSerializer);
    template.setValueSerializer(jsonSerializer);
    template.setHashValueSerializer(jsonSerializer);
    template.afterPropertiesSet();
    return template;
    }
    }

    // ============ 2. 领域对象:UserProfile 需要可被 JSON 序列化 ============
    package com.example.user.model;

    public record UserProfile(
    Long userId,
    String nickname,
    String avatarUrl,
    String email
    ) {
    public static UserProfile from(User user) {
    return new UserProfile(user.getId(), user.getNickname(),
    user.getAvatarUrl(), user.getEmail());
    }
    }

    // ============ 3. 缓存失效服务:重试 + 延迟双删 + MQ 补偿 ============
    package com.example.user.support;

    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Service;

    import java.util.concurrent.Executors;
    import java.util.concurrent.ScheduledExecutorService;
    import java.util.concurrent.TimeUnit;

    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class CacheInvalidationService {

    private static final int MAX_SYNC_RETRY = 3;
    private static final long SECOND_DELETE_DELAY_MS = 1000L;

    // 延迟双删用的定时线程(守护线程,不阻塞 JVM 退出)
    private static final ScheduledExecutorService DELAYED_DELETE_POOL =
    Executors.newSingleThreadScheduledExecutor(r -> {
    Thread t = new Thread(r, "cache-delayed-delete");
    t.setDaemon(true);
    return t;
    });

    private final RedisTemplate<String, Object> redisTemplate;
    // 生产环境注入 MqInvalidationPublisher(可选),delete 重试失败后发补偿消息

    /**
    * 删除缓存(带重试 + 延迟双删)。
    * 为什么延迟双删?——覆盖"线程 A 读旧值 → 线程 B 删缓存 → A 写回旧值"的并发窗口:
    * 第一次删除尽量缩短不一致窗口;第二次删除把 A 写回的脏值擦掉。
    * 注意:延迟双删不是严格正确(A 的写回若晚于第二次删除仍有理论窗口),
    * 所以真正的根治靠读路径的 single-flight(见 UserProfileService),双删只是兜底。
    */

    public void deleteCache(String key) {
    deleteWithRetry(key, MAX_SYNC_RETRY);
    DELAYED_DELETE_POOL.schedule(() -> deleteWithRetry(key, MAX_SYNC_RETRY),
    SECOND_DELETE_DELAY_MS, TimeUnit.MILLISECONDS);
    }

    private void deleteWithRetry(String key, int maxAttempts) {
    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
    redisTemplate.delete(key);
    return;
    } catch (Exception e) {
    log.warn("cache delete failed, key={}, attempt={}", key, attempt, e);
    try {
    Thread.sleep(100L * attempt); // 100ms / 200ms / 300ms 退避
    } catch (InterruptedException ie) {
    Thread.currentThread().interrupt();
    return;
    }
    }
    }
    // 同步重试仍失败:发 MQ 由独立消费者补偿删除(解耦、可持久化、可重试)
    log.error("cache delete failed after {} attempts, publishing compensation event, key={}",
    maxAttempts, key);
    // mqPublisher.publish(new CacheInvalidationEvent(key));
    }
    }

    // ============ 4. 核心服务:读路径 single-flight + 写路径 afterCommit 删缓存 ============
    package com.example.user.service;

    import com.example.user.model.User;
    import com.example.user.model.UserProfile;
    import com.example.user.repo.UserRepository;
    import com.example.user.support.CacheInvalidationService;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    import org.springframework.transaction.support.TransactionSynchronization;
    import org.springframework.transaction.support.TransactionSynchronizationManager;

    import java.time.Duration;
    import java.util.concurrent.ConcurrentHashMap;
    import java.util.concurrent.ThreadLocalRandom;
    import java.util.concurrent.CompletableFuture;

    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class UserProfileService {

    private static final Duration TTL_BASE = Duration.ofMinutes(30); // 基础 TTL
    private static final Duration TTL_JITTER = Duration.ofMinutes(5); // 随机抖动,防雪崩
    private static final Duration NULL_TTL = Duration.ofMinutes(5); // 空值缓存,防穿透
    private static final Duration LOCK_TTL = Duration.ofSeconds(3); // 回源锁生命周期(必须 > 回源耗时)
    private static final String KEY_PREFIX = "user:profile:";
    private static final String LOCK_PREFIX = "user:profile:lock:";

    private final UserRepository userRepository;
    private final RedisTemplate<String, Object> redisTemplate;
    private final CacheInvalidationService invalidationService;

    // single-flight:进程内按 key 合并并发回源(跨实例互斥由 Redis 分布式锁承担)
    private final ConcurrentHashMap<Long, CompletableFuture<UserProfile>> inFlight =
    new ConcurrentHashMap<>();

    // —————- 读路径 —————-

    public UserProfile getProfile(Long userId) {
    String key = KEY_PREFIX + userId;
    Object cached = redisTemplate.opsForValue().get(key);
    if (cached instanceof UserProfile profile) {
    return profile;
    }
    if (cached instanceof NullProfile) { // 命中空值缓存:直接 404,不再打 DB
    throw new UserNotFoundException(userId);
    }
    return singleFlightLoad(userId, key);
    }

    /**
    * single-flight 回源:同一 userId 的并发请求合并为一次 DB 查询。
    * 为什么能根治脏写回?——"查 DB + 写缓存"整个过程被合并/互斥,
    * 不会出现"读旧值→别人删缓存→我写回旧值"的交错。
    */

    private UserProfile singleFlightLoad(Long userId, String key) {
    CompletableFuture<UserProfile> future = inFlight.computeIfAbsent(userId,
    id -> CompletableFuture.supplyAsync(() -> loadFromDbWithMutex(id, key))
    .whenComplete((p, e) -> inFlight.remove(id)));
    try {
    return future.join();
    } catch (Exception e) {
    Throwable cause = e.getCause() instanceof RuntimeException re ? re : e;
    if (cause instanceof UserNotFoundException) {
    throw (UserNotFoundException) cause;
    }
    throw new IllegalStateException("load profile failed", cause);
    }
    }

    private UserProfile loadFromDbWithMutex(Long userId, String key) {
    String lockKey = LOCK_PREFIX + userId;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_TTL);
    if (Boolean.TRUE.equals(locked)) {
    try {
    // 拿到锁的线程:双重检查,避免"锁内又查一次"(其实 single-flight 已合并,这里是双保险)
    Object again = redisTemplate.opsForValue().get(key);
    if (again instanceof UserProfile profile) {
    return profile;
    }
    UserProfile profile = userRepository.findById(userId)
    .map(UserProfile::from)
    .orElse(null);
    if (profile == null) {
    // 防穿透:把"不存在"也缓存起来,短 TTL
    redisTemplate.opsForValue().set(key, NullProfile.INSTANCE, NULL_TTL);
    throw new UserNotFoundException(userId);
    }
    long ttlSeconds = TTL_BASE.toSeconds()
    + ThreadLocalRandom.current().nextLong(0, TTL_JITTER.toSeconds());
    redisTemplate.opsForValue().set(key, profile, Duration.ofSeconds(ttlSeconds));
    return profile;
    } finally {
    redisTemplate.delete(lockKey); // 回源完成主动释放锁
    }
    }
    // 没拿到锁:说明其他实例正在回源,短暂自旋等缓存
    for (int i = 0; i < 10; i++) {
    try {
    Thread.sleep(50);
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    break;
    }
    Object cached = redisTemplate.opsForValue().get(key);
    if (cached instanceof UserProfile profile) {
    return profile;
    }
    }
    // 等待超时仍无缓存(回源方失败):直接读 DB 兜底,但不写缓存(避免脏写回)
    return userRepository.findById(userId).map(UserProfile::from)
    .orElseThrow(() -> new UserNotFoundException(userId));
    }

    // —————- 写路径 —————-

    @Transactional
    public void updateNickname(Long userId, String nickname) {
    User user = userRepository.findById(userId)
    .orElseThrow(() -> new UserNotFoundException(userId));
    user.setNickname(nickname);
    userRepository.save(user);

    // 关键点 1:缓存删除必须等事务提交成功后再做(afterCommit),
    // 否则 delete 在 commit 之前执行:DB 回滚时缓存被白白删除(损失命中),
    // 更糟的是 delete 抛异常会把整个业务事务一起回滚(缓存操作不该影响业务成败)。
    // 关键点 2:afterCommit 保证"commit 成功 → 删缓存"的次序,把失败面收窄到 Redis 一侧。
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override
    public void afterCommit() {
    invalidationService.deleteCache(KEY_PREFIX + userId);
    }
    });
    }
    }

    // ============ 5. 空值标记(可被 JSON 序列化的 record) ============
    package com.example.user.service;

    /**
    * 空值缓存标记:表示"该 userId 不存在"。
    * 为什么不能直接存 null?——RedisTemplate 存 null 会抛异常;
    * 为什么要有独立类型?——区分"缓存未命中"(继续回源)与"确定不存在"(直接 404)。
    */

    public record NullProfile() {
    public static final NullProfile INSTANCE = new NullProfile();
    }

    // ============ 6.(可选)热点用户识别:命中计数超阈值 → 改用 logical expire ============
    package com.example.user.service;

    import lombok.RequiredArgsConstructor;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Component;

    import java.time.Duration;
    import java.time.Instant;
    import java.util.concurrent.ThreadLocalRandom;

    /**
    * logical expire(逻辑过期):缓存物理永不过期,value 携带逻辑过期时间。
    * 读时若逻辑过期:先返回旧值给调用方(用户无感知),后台抢锁刷新。
    * 彻底消灭击穿——过期瞬间没有任何请求会 miss 到 DB。
    * 适用于明星用户这类"高并发 + 高热点"的 Key;普通用户用短 TTL + 抖动即可。
    */

    @Component
    @RequiredArgsConstructor
    public class LogicalExpireCache {

    private final RedisTemplate<String, Object> redisTemplate;

    public record LogicalValue<T>(T value, Instant expireAt) {}

    public <T> T getOrRefresh(String key, Duration logicalTtl,
    Loader<T> loader, String lockKey) {
    Object cached = redisTemplate.opsForValue().get(key);
    if (cached instanceof LogicalValue<?> lv) {
    @SuppressWarnings("unchecked")
    T value = (T) lv.value();
    if (lv.expireAt().isAfter(Instant.now())) {
    return value; // 未逻辑过期:直接返回
    }
    // 逻辑过期:先返回旧值,后台抢锁刷新(stale-while-revalidate)
    if (Boolean.TRUE.equals(redisTemplate.opsForValue()
    .setIfAbsent(lockKey, "1", Duration.ofSeconds(5)))) {
    try {
    T fresh = loader.load();
    redisTemplate.opsForValue().set(key,
    new LogicalValue<>(fresh, Instant.now().plus(logicalTtl)));
    } finally {
    redisTemplate.delete(lockKey);
    }
    }
    return value;
    }
    // 物理 miss:同步加载(首访,无旧值可返回)
    T fresh = loader.load();
    redisTemplate.opsForValue().set(key,
    new LogicalValue<>(fresh, Instant.now().plus(logicalTtl)));
    return fresh;
    }

    @FunctionalInterface
    public interface Loader<T> {
    T load();
    }
    }

    关于锁生命周期的补充讲解:Redis 分布式锁的 TTL(这里取 3s)必须大于"临界区最坏执行时间"(DB 查询 + 网络 + 序列化 + 写缓存)。为什么?——因为 SET key NX EX 3 的锁在 3s 后自动消失;若回源耗时超过 3s,第二个线程会在锁过期后再次拿到锁,造成双重回源,锁就失效了。生产中更稳妥的做法是给锁续期(Watch Dog 机制,Redisson 内置),或者把锁 TTL 设得远大于回源耗时。反过来说,回源正常都在几十毫秒内完成,3s 锁 + 完成后主动释放,是最简单的够用方案。

    🧪 需要补充的测试

    测试目的关键点
    并发时序测试(A miss / B update) 验证修复后不再出现"旧值写回" 多线程编排:A miss → B update+delete → 断言缓存最终是新值
    delete 失败测试 验证 commit 成功 + delete 失败 → 走重试/补偿,且业务事务不回滚 Mock RedisTemplate 抛异常
    击穿/单飞测试 验证 N 个并发 miss 只触发 1 次 DB 查询 计数器统计 DB 调用次数
    穿透测试 验证不存在 userId 命中空值缓存,不打 DB 断言第二次调用不查库
    TTL 抖动测试 验证 TTL 落在 [30min, 35min) 区间 读取 Redis 剩余 TTL

    package com.example.user.service;

    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.Mock;
    import org.mockito.junit.jupiter.MockitoExtension;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.data.redis.core.ValueOperations;
    import com.example.user.model.User;
    import com.example.user.model.UserProfile;
    import com.example.user.repo.UserRepository;
    import com.example.user.support.CacheInvalidationService;

    import java.util.Optional;
    import java.util.concurrent.CountDownLatch;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.atomic.AtomicInteger;

    import static org.assertj.core.api.Assertions.assertThat;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.ArgumentMatchers.eq;
    import static org.mockito.Mockito.*;

    @ExtendWith(MockitoExtension.class)
    class UserProfileServiceTest {

    @Mock UserRepository userRepository;
    @Mock RedisTemplate<String, Object> redisTemplate;
    @Mock ValueOperations<String, Object> valueOperations;
    @Mock CacheInvalidationService invalidationService;

    UserProfileService service;

    @BeforeEach
    void setUp() {
    when(redisTemplate.opsForValue()).thenReturn(valueOperations);
    service = new UserProfileService(userRepository, redisTemplate, invalidationService);
    }

    @Test
    void concurrentMiss_shouldQueryDbOnlyOnce() throws Exception {
    // 单飞验证:20 个并发 miss,DB 只被查 1 次
    AtomicInteger dbCalls = new AtomicInteger();
    when(valueOperations.get("user:profile:1")).thenReturn(null);
    when(valueOperations.setIfAbsent(eq("user:profile:lock:1"), eq("1"), any()))
    .thenReturn(true);
    when(userRepository.findById(1L)).thenAnswer(inv -> {
    dbCalls.incrementAndGet();
    return Optional.of(user(1L, "old"));
    });

    int n = 20;
    CountDownLatch start = new CountDownLatch(1);
    CountDownLatch done = new CountDownLatch(n);
    ExecutorService pool = Executors.newFixedThreadPool(n);
    for (int i = 0; i < n; i++) {
    pool.submit(() -> {
    try {
    start.await();
    service.getProfile(1L);
    } catch (Exception ignored) {
    } finally {
    done.countDown();
    }
    });
    }
    start.countDown();
    done.await();
    pool.shutdown();

    assertThat(dbCalls.get()).isEqualTo(1);
    }

    @Test
    void notFoundUser_shouldBeCachedAsNull() {
    when(valueOperations.get("user:profile:999")).thenReturn(null);
    when(valueOperations.setIfAbsent(eq("user:profile:lock:999"), eq("1"), any()))
    .thenReturn(true);
    when(userRepository.findById(999L)).thenReturn(Optional.empty());

    org.junit.jupiter.api.Assertions.assertThrows(
    UserNotFoundException.class, () -> service.getProfile(999L));
    // 空值写入了缓存(防穿透)
    verify(valueOperations).set(eq("user:profile:999"), any(NullProfile.class), any());
    }

    @Test
    void updateNickname_deleteCacheMustRunAfterCommit() {
    when(userRepository.findById(1L)).thenReturn(Optional.of(user(1L, "old")));
    // 模拟"事务提交后删除缓存"
    service.updateNickname(1L, "new");
    // afterCommit 在事务提交时触发;这里直接断言注册了同步(事务管理器为 mock 时 afterCommit 不触发,
    // 集成测试中应配合 TransactionTemplate 验证"commit 成功后才 delete"的次序)
    verify(invalidationService, never()).deleteCache("user:profile:1");
    }

    private User user(Long id, String nickname) {
    User u = new User();
    u.setId(id);
    u.setNickname(nickname);
    return u;
    }
    }

    补充说明:上例中 updateNickname 的单测因为 TransactionSynchronizationManager 需要真实事务才触发 afterCommit,单测只能验证"未提交前不删缓存"。真正的集成测试应该用 @SpringBootTest + 真实 Redis(Testcontainers)+ TransactionTemplate,断言:事务回滚 → 缓存不删;事务提交 → 缓存删除且延迟 1s 后再次删除。

    💡 工程要点(教学总结)

    • 写路径三大纪律:先 DB 后缓存、commit 成功才删缓存(afterCommit)、删除失败必须重试/补偿——把"两系统无事务"的缺陷用"时序 + 重试"补上。
    • 读路径两大杀器:single-flight/mutex 消灭击穿与脏写回;空值缓存消灭穿透。"读-写回"不原子,就必须让并发读合并。
    • TTL 三原则:短(分钟级而非小时级)、随机(防雪崩)、只做兜底不做一致性修复。
    • 三兄弟口诀:穿透是"查不存在的 Key",击穿是"一个热点 Key 过期",雪崩是"一堆 Key 同时过期"——对策分别是空值缓存、单飞回源、随机 TTL。
    • 先定业务性质再定一致性:展示型数据接受秒级最终一致(有界),资金类数据不许缓存或严格对账。
    • 生产级细节:Redis 用 JSON 序列化而非 JDK 序列化;锁的 TTL 必须覆盖临界区耗时;多实例下进程内 single-flight 必须叠加分布式锁。

    题 2:文件下载接口(路径穿越 / 越权 / Content-Disposition / 大文件下载)

    🚩 审查 Issue(结论先行)

    问题位置风险等级问题类型触发条件可能后果
    download(@RequestParam String path) + new File(path)(L577-592) P0(高危:任意文件读取,直接影响机密性;且服务账户可读 /etc、/opt/app、/var/log) 路径穿越(Path Traversal) + 任意文件读取 攻击者直接请求 ?path=/etc/passwd 或 ?path=/data/uploads/../../etc/passwd 读取服务器任意文件:密码文件、应用配置(含数据库口令)、日志、源码——等同于服务器沦陷
    无任何鉴权/归属校验,任何登录用户(甚至匿名)可下载任何文件(L577) P0(越权访问控制缺失) 越权(IDOR / BOLA) 攻击者遍历 fileId / 猜测路径 下载他人私有附件:合同、证件、内部文档泄露
    "attachment; filename=" + file.getName()(L589) P1(头注入 + 文件名乱码) HTTP 响应头注入 文件名含 CRLF(%0d%0a)、引号、分号;中文文件名 注入任意响应头(如 Set-Cookie)实施会话固定/ XSS;中文文件名乱码或解析歧义
    大文件直接经 Spring MVC 返回 ResponseEntity<Resource>(L586-592) P1(可用性/性能风险) 大文件下载方案错误 文件几十 MB ~ 数 GB;并发下载增多 应用服务器占用大量 Tomcat worker 线程与网络带宽;无断点续传、无限速、无防重;线程池耗尽拖垮整个应用
    依赖"前端只会传数据库保存的路径"(开发者假设) P1(信任边界错误) 信任边界(Trust Boundary) 任何可构造 HTTP 请求的客户端 前端是不可信输入源,其"约定"不构成任何安全约束——这是本次所有漏洞的根因

    结论先行:这是一道 P0 级安全审查题。三个叠加的致命伤:信任前端传路径(路径穿越 + 任意文件读取)→ 无归属校验(越权下载)→ 头注入与性能方案错误。任何一条都足以让这个接口在安全评审中被打回重做。

    📖 深入讲解(为什么有问题)

    1. 漏洞是什么?风险定 P 几?
    • ?path=/etc/passwd:new File("/etc/passwd") 是绝对路径,直接读取系统文件。因为服务运行账户能访问 /etc/、/opt/app/、/var/log/,攻击者可以读取:/etc/passwd(用户列表)、/opt/app/application.yml(数据库口令、Redis 密码、云厂商 AK/SK)、/var/log/*.log(业务数据)、甚至 ~/.ssh/id_rsa。→ P0,任意文件读取。
    • ?path=/data/uploads/../../etc/passwd:.. 目录回溯。文件系统解析路径时,/data/uploads/../../etc/passwd 规范化后等于 /etc/passwd。→ P0,路径穿越。
    • 两个请求等价,都是 P0。为什么是 P0 而不是 P1?——因为这不是"特定条件下才触发的边缘 bug",而是任何知道接口的人都可稳定利用的远程任意文件读取漏洞,属于 CWE-22 / OWASP A01(Broken Access Control)级别的最高危问题。
    2. 为什么不能相信前端参数?

    因为参数来自客户端 = 攻击者可控。开发者的假设"前端只会传数据库保存的附件路径"把安全建立在一个没有强制力的约定上:

    • 前端代码可以被任意修改(改 JS、用 curl/Postman 直接构造请求);
    • 就算前端是自家 SPA,也无法阻止攻击者绕过前端直接打接口;
    • 安全模型里,所有客户端输入都默认不可信,这是信任边界(Trust Boundary)的基本原则——服务端必须假设请求里任何字段都可能被篡改,包括 path、fileId、userId、Cookie 等。

    信任边界口诀:能到达服务端的每一个字节,都默认来自攻击者。

    3. path.startsWith("/data/uploads/") 能不能彻底修?

    不能,它连第一关都过不了,更别提软链接。

    • 字符串前缀 ≠ 路径规范化:startsWith 是纯字符串比较,不做路径解析。/data/uploads/../etc/passwd 字符串前缀确实是 /data/uploads/(/data/uploads/.. 的前 15 个字符完全匹配),但文件系统解析后是 /etc/passwd。前缀检查在"…/"面前形同虚设。
    • 绕过变体无穷无尽:/data/uploads/../../etc/passwd、/data/uploads/..%2f..%2fetc/passwd(URL 编码)、/data/uploads/%2e%2e/%2e%2e/etc/passwd、双写 ….//、/data/uploads2/../../etc/passwd(前缀只匹配到 /data/uploads,/data/uploads2 也以它开头!)、符号链接、硬链接、/data/uploads/././../../etc/passwd……
    • 即使你用 Path.normalize() 之后再 startsWith(这是比字符串前缀好得多的做法),仍然挡不住:
    4. Symbolic Link 会不会绕过?

    会,而且非常干净地绕过。 假设攻击者能在 /data/uploads/ 下创建软链接(或者数据库里某条附件记录的 storageKey 恰好指向软链接):

    /data/uploads/link_to_etc -> /etc (软链接)
    GET /api/files/download?path=/data/uploads/link_to_etc/passwd

    normalize() 后的路径是 /data/uploads/link_to_etc/passwd——前缀合法、规范化合法,但 File/FileInputStream 默认跟随符号链接,实际读到的是 /etc/passwd。Files.isRegularFile() 也会跟随链接判断为目标文件。结论:任何"校验字符串路径"的方案都无法防御软链接,因为问题出在"字符串 → inode"的解析层。 防御手段只有两条路:① 用 Files.isSymbolicLink() 显式拒绝 + 解析真实路径(toRealPath())后再校验;② 根除客户端传路径的设计(推荐,见下)。

    5. 应该让客户端传什么:文件路径 / fileId / objectKey?
    传参安全性结论
    文件路径 路径解析在服务端,攻击者可控 → 穿越/软链接 ❌ 禁止
    fileId(自增数字主键) 只是"凭证",真正的存储路径在服务端 DB 里,客户端碰不到 ✅ 推荐(但要配合归属校验防 IDOR)
    objectKey(对象存储逻辑键,如 2024/08/17/uuid.pdf) 服务端生成(UUID 随机命名),客户端不可控 ✅ 推荐(配合 pre-signed URL 时尤其适合)

    核心原则:客户端只传"引用",不传"位置"。 存储路径/objectKey 由服务端生成并只存在 DB 里(storageKey 列),下载时服务端按 fileId 查出 storageKey 再访问。这样路径穿越的攻击面从协议层直接消失——因为攻击者根本没有机会指定路径。同时存储文件名应该用 UUID 随机命名(uuid.pdf),而不是用户原始文件名——原始文件名只存 DB(original_filename 列)用于下载时的 Content-Disposition 展示。

    6. 如何验证"这个文件属于当前用户"?

    归属校验必须在服务端基于可信身份做,且发生在任何文件访问之前:

    请求带登录凭证(Session / JWT)
    → 服务端解析出 currentUserId(可信身份)
    → 查 attachment 表:WHERE id = :fileId
    → 校验 att.owner_id == currentUserId (或按业务查共享/权限表)
    → 通过后才允许读取存储文件

    为什么必须查库校验而不是信任请求里的 userId?——因为 ?fileId=1&userId=2 这种请求里,userId 也是客户端可控的;必须从服务端持有的登录态(SecurityContext / Session)里取身份。另外还要注意:先鉴权后访问(不能先读了文件再校验),并且文件不存在时返回 404 而非 403 的差异要注意(是否暴露资源存在性,视业务而定)。

    7. Content-Disposition 有没有额外风险?

    有,两个:

  • 响应头注入(CRLF Injection):filename 直接拼进响应头。若文件名含 %0d%0a(CRLF),如 filename="a.txt\\r\\nSet-Cookie: admin=1",就注入了任意响应头,可能实施会话固定、缓存投毒、配合反射型 XSS。修复:文件名先净化(去掉 \\r \\n " 等控制字符),再用引号包裹 + RFC 5987 编码。
  • 文件名乱码/歧义:attachment; filename=中文.pdf 在旧浏览器会乱码。标准做法是双轨:filename="fallback.pdf"(ASCII 兜底)+ filename*=UTF-8''%E4%B8%AD%E6%96%87.pdf(RFC 5987 编码)。Spring 的 ContentDisposition.attachment().filename(name, UTF_8) 已封装此逻辑,应直接使用,不要手拼字符串。
  • 8. 大文件应该直接通过 Spring MVC 返回吗?

    不应该(分情况)。 ResponseEntity<Resource> 虽然底层是流式的(FileSystemResource 不会整体读入内存),但数据仍然要经过应用服务器:

    • Tomcat 每个请求占用一个 worker 线程,文件下载是长连接,大文件下载会让 worker 线程长时间被"传输文件"占住 → 线程池耗尽 → 其他 API 全部排队;
    • 应用服务器转发字节流,浪费应用带宽和 CPU(无零拷贝);
    • 没有断点续传(Range 请求需自己实现)、没有限速、没有并发防重。

    三个正确的工程化选择(按存储形态选):

    方案原理适用优点缺点
    Nginx X-Accel-Redirect 应用只做鉴权,返回 X-Accel-Redirect: /internal/xxx 头;Nginx 收到后自己读磁盘文件发送(sendfile 零拷贝),请求从应用卸载 文件在本地磁盘 应用线程零传输开销;Nginx 原生支持 Range/断点续传、限速 limit_rate;配置简单 需与 Nginx 约定 internal location;文件系统需多实例共享(NFS/共享盘)或部署一致
    S3 Pre-signed URL 文件在对象存储;应用鉴权后生成带签名、限时的临时 URL(如 15 分钟),返回 302/JSON,客户端直连 OSS 下载 文件在 OSS/S3/COS 下载流量完全不经过应用;天然支持断点续传、CDN;签名可控(限时、限操作) 依赖对象存储;预签名 URL 泄露窗口(限时可缓解);需要管理 bucket 权限
    流式下载(StreamingResponseBody / InputStreamResource) 应用流式写响应 中小文件(<50MB)、无 Nginx 层 实现简单,内存可控 仍占用 Tomcat 线程与带宽;无断点续传/限速

    推荐组合:本地盘 + Nginx → X-Accel-Redirect;对象存储 → Pre-signed URL + 302;只有小文件且无前置 Nginx 时才用流式。另外务必支持 Range 请求(Accept-Ranges: bytes + 206 响应),这是断点续传和视频拖动的基石——Nginx 和 OSS 都原生支持,而手写 Spring 流式则要自己实现。

    9. 更合理的下载 API 设计(额外要求)

    目标形态:GET /api/files/{fileId}/download,后端内部流程:

    1. 认证:从 SecurityContext 取当前登录用户(JWT/Session),未登录 → 401
    2. 定位:按 fileId 查 attachment 表 → 不存在 → 404
    3. 授权:att.owner_id == currentUserId(或查共享权限表)→ 不通过 → 403
    4. 状态:文件状态 ACTIVE?→ 已删除/禁用 → 404
    5. 解析:用 DB 里的 storageKey 解析真实存储路径(服务端生成,杜绝客户端注入)
    6. 存在性:文件在存储上存在?→ 不存在 → 404
    7. 响应:
    – 本地盘:返回 X-Accel-Redirect(Nginx 发文件)+ 安全 Content-Disposition
    – 对象存储:生成 pre-signed URL → 302 重定向
    8. 审计(可选):记录下载日志(谁、何时、哪个文件、来源 IP)

    🛠 解决方案

    候选方案对比 → 推荐方案:推荐"fileId 引用 + 服务端归属校验 + 本地盘用 X-Accel / 对象存储用 Pre-signed URL"组合;彻底放弃客户端传路径。防御纵深四层:① 协议层消灭路径输入(传 fileId)→ ② 鉴权层归属校验 → ③ 存储层 UUID 随机命名 + 目录隔离 → ④ 传输层 Nginx/OSS 卸载。

    // ============ 1. 附件实体:storageKey 服务端生成,绝不接受客户端路径 ============
    package com.example.file.model;

    import jakarta.persistence.Column;
    import jakarta.persistence.Entity;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.GenerationType;
    import jakarta.persistence.Id;
    import jakarta.persistence.Table;
    import lombok.Getter;
    import lombok.Setter;

    import java.time.LocalDateTime;

    @Entity
    @Table(name = "attachment")
    @Getter
    @Setter
    public class Attachment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "owner_id", nullable = false)
    private Long ownerId; // 归属用户:鉴权依据

    @Column(name = "storage_key", nullable = false)
    private String storageKey; // 服务端生成的相对存储路径,如 "2024/08/17/550e8400-….pdf"

    @Column(name = "original_filename", nullable = false)
    private String originalFilename; // 用户原始文件名:仅用于下载时的展示命名

    @Column(name = "content_type")
    private String contentType;

    @Column(name = "file_size", nullable = false)
    private Long fileSize;

    @Column(name = "status", nullable = false)
    private String status; // ACTIVE / DELETED

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;
    }

    // ============ 2. 存储方案抽象:本地盘 or 对象存储,二选一实现 ============
    package com.example.file.storage;

    import java.net.URI;

    /**
    * 下载目标:服务端完成鉴权后,决定"怎么把文件交给客户端"。
    */

    public sealed interface DownloadTarget permits LocalFileTarget, ObjectStorageTarget {

    String safeFileName();

    long size();

    String contentType();
    }

    /** 本地盘文件:交给 Nginx X-Accel-Redirect 发送 */
    public record LocalFileTarget(
    String internalUrl, // 例如 /protected/2024/08/17/uuid.pdf(Nginx internal location)
    String safeFileName,
    long size,
    String contentType
    ) implements DownloadTarget {
    @Override public String safeFileName() { return safeFileName; }
    @Override public long size() { return size; }
    @Override public String contentType() { return contentType; }
    }

    /** 对象存储:交给 302 重定向到预签名 URL */
    public record ObjectStorageTarget(
    URI preSignedUrl,
    String safeFileName,
    long size,
    String contentType
    ) implements DownloadTarget {
    @Override public String safeFileName() { return safeFileName; }
    @Override public long size() { return size; }
    @Override public String contentType() { return contentType; }
    }

    // ============ 3. 文件服务:归属校验 + 存储解析(核心安全逻辑) ============
    package com.example.file.service;

    import com.example.file.model.Attachment;
    import com.example.file.repo.AttachmentRepository;
    import com.example.file.storage.DownloadTarget;
    import com.example.file.storage.LocalFileTarget;
    import com.example.file.storage.ObjectStorageTarget;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.stereotype.Service;

    import java.nio.file.Files;
    import java.nio.file.Path;
    import java.time.Duration;
    import java.util.UUID;

    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class FileService {

    private static final Path STORAGE_ROOT = Path.of("/data/uploads").toAbsolutePath().normalize();

    private final AttachmentRepository attachmentRepository;
    private final PreSignedUrlProvider preSignedUrlProvider; // 对象存储时注入

    @Value("${file.storage.type:local}") // local | s3
    private String storageType;

    /**
    * 解析下载目标。整个方法的核心:客户端只提供 fileId,
    * 存储路径完全来自服务端 DB,客户端没有任何注入路径的机会。
    *
    * @throws FileAccessDeniedException 403:文件不属于当前用户
    * @throws AttachmentNotFoundException 404:文件不存在/已删除
    */

    public DownloadTarget resolveDownload(Long fileId, Long currentUserId) {
    Attachment att = attachmentRepository.findById(fileId)
    .orElseThrow(() -> new AttachmentNotFoundException(fileId));

    // ① 归属校验:文件必须属于当前登录用户(服务端可信身份,非请求参数)
    if (!att.getOwnerId().equals(currentUserId)) {
    log.warn("download denied: fileId={}, user={}, owner={}",
    fileId, currentUserId, att.getOwnerId());
    throw new FileAccessDeniedException(fileId);
    }

    // ② 状态校验:已删除/禁用一律视为不存在(避免泄露资源存在性)
    if (!"ACTIVE".equals(att.getStatus())) {
    throw new AttachmentNotFoundException(fileId);
    }

    // ③ 按存储形态分发
    return "s3".equals(storageType)
    ? toObjectStorageTarget(att)
    : toLocalFileTarget(att);
    }

    /**
    * 本地盘:解析 storageKey,防御性校验路径在 STORAGE_ROOT 之下。
    * 为什么还要校验?——storageKey 由服务端生成,理论上不会越界,
    * 但 DB 可能被历史脏数据/误操作污染,且未来可能有人手工改库;
    * 校验是"纵深防御"的最后一道,代价几乎为零。
    * 注意:软链接无法靠字符串校验防御,真正防御是"storageKey 永不由客户端提供"。
    */

    private LocalFileTarget toLocalFileTarget(Attachment att) {
    Path target = STORAGE_ROOT.resolve(att.getStorageKey()).normalize();
    if (!target.startsWith(STORAGE_ROOT)) {
    log.error("storage key escapes root, fileId={}, storageKey={}",
    att.getId(), att.getStorageKey());
    throw new IllegalStateException("invalid storage key");
    }
    if (!Files.exists(target) || !Files.isRegularFile(target)) {
    throw new AttachmentNotFoundException(att.getId());
    }
    // 相对 internal 路径(供 Nginx 使用,必须去掉根前缀)
    String internalUrl = "/protected/" + STORAGE_ROOT.relativize(target)
    .toString().replace('\\\\', '/');
    return new LocalFileTarget(internalUrl, att.getOriginalFilename(),
    att.getFileSize(), att.getContentType());
    }

    /** 对象存储:生成限时预签名 URL(15 分钟),客户端直连下载 */
    private ObjectStorageTarget toObjectStorageTarget(Attachment att) {
    return new ObjectStorageTarget(
    preSignedUrlProvider.signGet(att.getStorageKey(),
    att.getOriginalFilename(), Duration.ofMinutes(15)),
    att.getOriginalFilename(), att.getFileSize(), att.getContentType());
    }
    }

    // ============ 4. Controller:只收 fileId,不再收 path ============
    package com.example.file.web;

    import com.example.file.service.FileService;
    import com.example.file.storage.DownloadTarget;
    import com.example.file.storage.LocalFileTarget;
    import com.example.file.storage.ObjectStorageTarget;
    import com.example.security.CurrentUser;
    import lombok.RequiredArgsConstructor;
    import org.springframework.http.HttpHeaders;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.MediaType;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.RestController;

    import java.net.URI;

    @RestController
    @RequiredArgsConstructor
    public class FileController {

    private final FileService fileService;

    /**
    * 下载接口:客户端只传 fileId,路径/归属/存储解析全部在服务端完成。
    * 两种响应形态:
    * 本地盘 → X-Accel-Redirect(Nginx 用 sendfile 零拷贝发文件)
    * 对象存储 → 302 重定向到预签名 URL(下载流量不经过应用)
    */

    @GetMapping("/api/files/{fileId}/download")
    public ResponseEntity<?> download(@PathVariable Long fileId) {
    Long currentUserId = CurrentUser.id(); // 服务端可信身份
    DownloadTarget target = fileService.resolveDownload(fileId, currentUserId);

    HttpHeaders headers = new HttpHeaders();
    headers.setContentDisposition(ContentDispositionUtil.safeAttachment(target.safeFileName()));
    headers.setContentType(MediaType.parseMediaType(target.contentType()));

    if (target instanceof LocalFileTarget local) {
    // X-Accel-Redirect:应用只做鉴权定位,实际发送交给 Nginx
    headers.add("X-Accel-Redirect", local.internalUrl());
    headers.setContentLength(local.size());
    return new ResponseEntity<>(headers, HttpStatus.OK);
    }

    if (target instanceof ObjectStorageTarget oss) {
    // 302 → 客户端直连对象存储
    return ResponseEntity.status(HttpStatus.FOUND)
    .location(URI.create(oss.preSignedUrl().toString()))
    .headers(headers)
    .build();
    }
    throw new IllegalStateException("unsupported download target");
    }
    }

    // ============ 5. Content-Disposition 安全工具:防头注入 + RFC 5987 中文文件名 ============
    package com.example.file.web;

    import org.springframework.http.ContentDisposition;

    import java.nio.charset.StandardCharsets;

    /**
    * 安全构造 Content-Disposition:
    * ① 过滤 CR/LF/引号等控制字符,杜绝响应头注入(CRLF Injection);
    * ② 中文等非 ASCII 文件名走 RFC 5987 filename* 编码(避免乱码)。
    * 为什么不手拼字符串?——手拼容易漏过滤;Spring 的 ContentDisposition
    * 已实现 filename 转义与 filename* 双轨,直接复用标准实现。
    */

    public final class ContentDispositionUtil {

    private ContentDispositionUtil() {
    }

    public static ContentDisposition safeAttachment(String rawFilename) {
    // 只保留安全字符:过滤 \\r \\n " 以及一切控制字符(防御 CRLF 头注入)
    String clean = (rawFilename == null ? "download" : rawFilename)
    .replaceAll("[\\\\r\\\\n\\"]", "_")
    .replaceAll("[\\\\p{Cntrl}]", "_")
    .replaceAll("[\\\\\\\\/]", "_");
    // 最长截断,避免超长文件名带来的解析歧义/日志放大
    if (clean.length() > 200) {
    clean = clean.substring(0, 200);
    }
    return ContentDisposition.attachment()
    .filename(clean, StandardCharsets.UTF_8)
    .build();
    }
    }

    // ============ 6.(对象存储)AWS S3 预签名 URL 提供者 ============
    package com.example.file.storage;

    import lombok.RequiredArgsConstructor;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.stereotype.Component;
    import software.amazon.awssdk.services.s3.S3Client;
    import software.amazon.awssdk.services.s3.model.GetObjectRequest;
    import software.amazon.awssdk.services.s3.presigner.S3Presigner;
    import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;

    import java.net.URI;
    import java.time.Duration;

    @Component
    @RequiredArgsConstructor
    public class PreSignedUrlProvider {

    @Value("${s3.bucket}")
    private String bucket;

    private final S3Presigner presigner;

    /**
    * 生成限时预签名 GET URL:
    * – 签名期限(15 分钟)内有效,泄露窗口可控;
    * – response-content-disposition 参数让 OSS 直接返回安全下载头,
    * 无需应用再设置(注意:此处示例简化,生产需对文件名做与
    * ContentDispositionUtil 相同的净化)。
    */

    public URI signGet(String objectKey, String fileName, Duration ttl) {
    GetObjectRequest getRequest = GetObjectRequest.builder()
    .bucket(bucket)
    .key(objectKey)
    .build();
    GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
    .signatureDuration(ttl)
    .getObjectRequest(getRequest)
    .build();
    return presigner.presignGetObject(presignRequest).url().toURI();
    }
    }

    # ============ 7. Nginx 配置:internal location + 零拷贝 + 限速 ============
    # 关键点:
    # – internal:该 location 只能被 X-Accel-Redirect 头触发,外部 URL 直接访问返回 404;
    # – alias 必须与后端 STORAGE_ROOT 一致;
    # – sendfile:零拷贝,应用服务器不再参与文件字节流传输;
    # – limit_rate:限制单连接下载速度,防止单用户占满出口带宽;
    # – 原生支持 Range 请求(断点续传)。

    location /protected/ {
    internal; # 仅允许 X-Accel-Redirect 内部跳转
    alias /data/uploads/; # 注意 alias 与 root 的路径拼接差异
    sendfile on;
    sendfile_max_chunk 1m;
    limit_rate 10m; # 每连接 10MB/s 限速(按需配置)
    add_header Accept-Ranges bytes; # 断点续传
    }

    🧪 需要补充的测试

    测试目的关键点
    路径穿越攻击测试 验证 /etc/passwd、../../、编码变体全部被拒 对老接口的回归用例(证明旧接口确实可被利用)+ 新接口无 path 参数
    越权测试 验证用户 A 下载用户 B 的文件返回 403 不同登录态交叉下载
    软链接测试 验证符号链接无法读取根目录外文件 在存储目录造软链接再访问
    头注入测试 验证文件名含 CRLF/引号被净化 断言响应头无换行注入
    大文件/断点续传测试 验证 Range 请求返回 206 模拟下载中断后从断点续传

    package com.example.file.web;

    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.http.HttpHeaders;
    import org.springframework.test.web.servlet.MockMvc;

    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

    @SpringBootTest
    @AutoConfigureMockMvc
    class FileSecurityTest {

    @Autowired MockMvc mockMvc;

    // 回归:旧接口的路径穿越(若仍存在 path 参数,应全部拒绝)
    @Test
    void oldStylePathTraversal_mustBeRejected() throws Exception {
    mockMvc.perform(get("/api/files/download")
    .param("path", "/etc/passwd"))
    .andExpect(status().is4xxClientError());

    mockMvc.perform(get("/api/files/download")
    .param("path", "/data/uploads/../../etc/passwd"))
    .andExpect(status().is4xxClientError());
    }

    // 新接口:只接受 fileId,且必须带登录态
    @Test
    void newApi_requiresAuthentication() throws Exception {
    mockMvc.perform(get("/api/files/1/download"))
    .andExpect(status().isUnauthorized()); // 未登录 → 401
    }

    // 越权:user2 下载 user1 的文件 → 403
    @Test
    void crossUserDownload_mustBeForbidden() throws Exception {
    mockMvc.perform(get("/api/files/1/download")
    .header(HttpHeaders.AUTHORIZATION, "Bearer token-of-user2"))
    .andExpect(status().isForbidden());
    }

    // 头注入:恶意文件名被净化,响应头不含 CRLF
    @Test
    void maliciousFileName_mustNotInjectHeader() throws Exception {
    // 在 DB 中构造 original_filename = "a.txt\\r\\nSet-Cookie: hack=1"
    // 下载后断言响应头不含 Set-Cookie、不含换行符
    mockMvc.perform(get("/api/files/2/download")
    .header(HttpHeaders.AUTHORIZATION, "Bearer token-of-owner"))
    .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
    org.hamcrest.Matchers.not(
    org.hamcrest.Matchers.containsString("\\r\\n"))));
    }
    }

    💡 工程要点(教学总结)

    • 信任边界是安全第一原则:客户端传来的每个字段都默认不可信;"前端只会传合法值"不是安全设计,是事故预告。
    • 只传引用、不传位置:API 传 fileId/objectKey,存储路径由服务端 DB 解析——路径穿越从协议层消失,比任何"校验路径"都彻底。
    • 路径校验的三大误区:startsWith 是字符串比较不是路径解析(被 ../ 秒破);normalize() 挡不住软链接(要 toRealPath() + 拒绝符号链接);"数据库里的路径"也可能被脏数据污染(纵深防御仍要校验)。
    • 鉴权三问:身份从哪来(服务端登录态,不是请求参数)?归属怎么验(owner_id == 当前用户)?顺序对不对(先鉴权后访问)?
    • 大文件下载是架构问题不是接口问题:应用层转发大文件 = 用最贵的资源(Tomcat 线程)干最脏的活(传字节);本地盘交给 Nginx sendfile 零拷贝,对象存储交给预签名 URL 直连,顺带白拿断点续传、限速、CDN。
    • 响应头也要当输入处理:任何拼接进响应头的用户数据都要净化 + 标准编码(ContentDisposition + RFC 5987)。

    题 3:RabbitMQ 消费者(at-least-once / 重复消费 / Poison Message / 消费幂等)

    🚩 审查 Issue(结论先行)

    问题位置风险等级问题类型触发条件可能后果
    PointConsumer.consume():无任何幂等处理,直接 addPoints(L672-683) P0(资金/权益类数据直接错误) 重复消费(at-least-once 语义下未做幂等) 消息处理成功但 ACK 丢失/连接断开 → 重新投递 → 再次执行 积分 +200 而不是 +100——线上日志时间线已完整复现(08:10:03 与 08:10:04 两次 insert success)
    开发者提出的 catch (Exception e) { log.error(…); } 吞异常方案(L715-720) P0(比重复消费更严重:数据丢失且无感知) 静默吞异常 = 消息丢失 任何一次消费抛异常(DB 故障、代码 bug、消息格式错误) 消息被 ack 出队、业务失败永远丢失,无补偿机会、无告警——“不会无限重试"的代价是"任务永远消失”
    无死信队列(DLQ)配置、acknowledge-mode: auto 下无重试上限 P1(稳定性/可用性) Poison Message + 无限 requeue 某条消息持续抛异常(数据问题/代码 bug) 消息无限重投:日志爆炸、消费线程被毒消息占满、DB 被反复打、队列吞吐归零
    无消费幂等表 / 无消费监控告警 P1(工程治理缺失) 可观测性缺失 任何消费异常/积压 问题只能在用户投诉后才发现,无法快速定位

    结论先行:RabbitMQ 默认给出的是 at-least-once 投递语义,重复消费不是"异常情况",而是默认现实。这道题的错误修复方案(吞异常)把"重复"变成了"丢失",属于 P0 级错误。正确解法 = 消费幂等(同事务幂等表)+ 有限重试 + DLQ 兜底 + 监控告警。

    📖 深入讲解(为什么有问题)

    1. MQ 本质上提供哪种投递语义?
    语义含义RabbitMQ 何时提供
    at-most-once(至多一次) 消息可能丢,但不会重复 auto ack 模式:投递即确认,消费者在处理前崩溃 → 消息已从队列删除 → 丢失
    at-least-once(至少一次) 消息不会丢,但可能重复 manual ack / Spring auto ack(处理后确认):消费者处理成功但 ACK 未送达(网络断、连接断开)→ 消息重新投递 → 重复消费。RabbitMQ 默认/最常见的语义
    exactly-once(恰好一次) 既不丢也不重 原生 RabbitMQ 不存在。需要"消息存储 + 消费者幂等 + 去重存储"三方配合(如事务消息 + 幂等表),本质上是 at-least-once + 业务幂等拼出来的

    为什么"重复"是默认现实?——因为 RabbitMQ 无法知道"消费者是否真的处理完了"。它只认 ACK:basicAck 到达之前,消息处于"未确认"状态,连接断开就会重新投递。而"处理成功"和"ACK 送达"是两个独立事件,中间隔着网络,任何一秒的断连都会造成"处理了但没 ACK"→ 重投。这就是为什么每个消费者都必须假设"同一消息我会收到多次"。

    2. 回放线上时间线:为什么积分 +200?

    08:10:01 message received 第 1 次投递
    08:10:01 point DB timeout DB 故障,addPoints 抛异常
    08:10:02 consumer throws exception 方法抛异常 → Spring 不 ack(消息还在队列)
    08:10:03 RabbitMQ redelivery 第 2 次投递
    08:10:03 points insert success DB 恢复,插入成功
    08:10:03 consumer connection broken ACK 还没来得及发,连接断了!
    08:10:04 RabbitMQ redelivery 第 3 次投递(RabbitMQ 认为第 2 次没确认)
    08:10:04 points insert success 又插入一次

    关键在 08:10:03→04:第二次处理已成功(insert success),但 ACK 因连接断开丢失,RabbitMQ 判定"未确认"重新投递,第三次又成功插入。处理成功 ≠ 消息确认,这就是 at-least-once 的重复窗口。没有幂等表,这个重复就是实打实的 +200。

    3. DB 成功、ACK 失败会怎样?

    消息会重新投递,消费者会再次执行业务。 如果业务副作用(加积分)不可幂等,就重复生效。这是 at-least-once 的标准代价。对策只有一条:让业务副作用可幂等(见"消费幂等"),而不是祈祷 ACK 不丢。

    4. catch 掉 Exception 后会怎样?(为什么是错误修复)

    后果链:消费抛异常 → catch 吞掉 → 方法正常返回 → Spring 认为处理成功 → auto ack → 消息从队列删除 → 业务副作用永远缺失 → 无重试、无告警、无补偿入口。

    • 它确实"不再无限重试"了,但以消息丢失为代价:DB 故障期间进来的所有积分消息全部静默消失,用户积分永久少加;
    • 更糟的是掩盖故障:log.error 在日志海里,没有指标告警,DB 故障恢复后没人知道有多少消息丢了;
    • 正确的"不无限重试"应该是:有限次重试 → 进 DLQ → 告警 → 人工/补偿任务处理,而不是一次失败就放弃。

    教训:在 MQ 消费者里 catch 住所有异常 = 主动丢消息。 消费者代码里唯一允许"吞掉并继续"的异常是"幂等冲突"(已消费过),其他异常一律抛出交给重试/DLQ 机制。

    5. Poison Message(毒消息)怎么处理?

    Poison Message = 持续抛异常、永远处理不了的消息(如消息体缺字段、业务数据损坏)。如果不设上限,它会被无限 requeue:

    • 消费线程反复处理同一毒消息 → 线程被占满 → 队列里其他正常消息全部积压(队头阻塞);
    • 每次处理都打 DB/写日志 → 日志爆炸、DB 压力放大;
    • 重启也解决不了——消息还在队列里。

    正确姿势(三层):

  • 有限重试:max-attempts=3 + 指数退避(1s→2s→4s),给"瞬时故障"(DB 抖动)恢复机会;
  • 重试耗尽 → 拒绝 → DLQ:defaultRequeueRejected=false + 队列配置 x-dead-letter-exchange,毒消息进死信队列,不再打扰正常队列;
  • DLQ 侧处理:专门消费者把死信落库(错误明细)+ 告警;人工排查根因后,修复后手动/定时重投或补偿。
  • 6. 无限 retry 有什么风险?
    • 吞吐归零:毒消息反复占用消费者线程,正常消息排队(队头阻塞);
    • 日志/监控风暴:每秒重试的异常刷爆日志,掩盖真实问题;
    • 下游被反复打击:每次重试都打 DB/调外部,故障的 DB 被雪上加霜;
    • 消息TTL/积压:配合 TTL 或队列长度限制,最终触发消息过期丢弃或队列阻塞。

    结论:retry 必须有界(次数 + 退避),有界之后必须分流(DLQ),分流之后必须处理(告警 + 人工/补偿)。

    7. DLQ 应该承担什么职责?

    DLQ(死信队列)的职责是**“隔离 + 暂存 + 触发治理”**,不是"垃圾箱":

  • 隔离:把毒消息从正常队列剥离,保住正常业务吞吐;
  • 暂存:消息带着失败原因(x-death 头)等待人工/系统处理,可重投、可回放;
  • 触发治理:DLQ 消息数 > 0 就要告警;配套死信落库表 + 重投工具 + 补偿 Job;
  • 边界:DLQ 不负责"修 bug"——它负责"把问题暴露出来",根因修复靠人。
  • 8. 积分系统怎么实现消费幂等?

    方案:消费幂等表(本地事务)。

    设计:consume_record(event_id, consumer_name, order_id, processed_at)
    约束:UNIQUE(event_id, consumer_name)

    消费流程(同一 DB 事务):
    ① INSERT consume_record(…) — 抢唯一键
    ② 若唯一键冲突(已存在)→ 已消费过 → 直接跳过
    ③ 若无冲突 → UPDATE user_point SET points = points + 100
    ④ 事务提交 → ①和③同生共死

    为什么用 event_id 而不是 order_id?——一个订单可能产生多个事件(OrderCreated、OrderPaid、OrderShipped……),每个事件的消费记录要分开;而且同一事件可能被不同消费者(积分、库存、通知)各自消费,所以唯一键必须是 (event_id, consumer_name) 二元组。

    UNIQUE(event_id, consumer_name) 是否足够?——足够兜住"并发重复",但必须满足一个前提:记录插入与业务副作用在同一个事务里。 分析:

    • 场景 1:两个实例同时消费同一消息(极端情况下 RabbitMQ 可能对同一消息并发投递,或重投与手动重放撞车)→ 只有一个 INSERT 成功,另一个撞唯一键 → 幂等成立 ✅;
    • 场景 2:INSERT 成功但加积分失败 → 事务回滚 → 记录也没了 → 重投后重新执行 ✅(前提:同一事务!);
    • 场景 3:如果记录和积分不在同一事务(先插记录、提交,再单独加积分):加积分失败 → 记录已提交 → 重投时撞唯一键直接跳过 → 积分永久丢失 ❌。这就是题目问"记录消费与加积分是否同一事务"的原因——必须同一事务。

    补充:如果不想用"先插后改"的顺序,也可以用 INSERT … ON DUPLICATE KEY UPDATE / INSERT IGNORE 一步完成抢键(见解决方案代码),语义更清晰。

    为什么不用 Redis 做幂等?——Redis 的 SETNX 也可以去重,但:① 与 DB 事务没有原子性,记录消费成功但加积分失败时无法回滚去重标记;② 资金/权益类数据必须落在持久化存储上,Redis 丢数据就是丢权益;③ 幂等记录还要支持对账查询。结论:权益类幂等用 DB 事务,Redis 幂等只适合日志、埋点这类可容忍丢失的场景。

    9. 补充:Spring 的 auto ack 与 manual ack 怎么选?
    • acknowledge-mode: auto(Spring 默认行为):方法正常返回 → ack;抛异常 → 由容器决定 requeue(配合 retry 配置)。够用且简单,配合"有限重试 + DLQ"即可;
    • acknowledge-mode: manual:自己调 channel.basicAck/basicNack,适合需要精确控制确认时机(如处理完但想延迟确认)的场景。不强制,auto + 幂等 + DLQ 已是生产级配置;
    • 红线:无论哪种模式,都不要"catch 住异常当成功"——那等于手动制造消息丢失。

    🛠 解决方案

    候选方案对比 → 推荐方案:

    方案原理优点缺点结论
    吞异常(开发者的提案) catch 全部异常 不重试 消息静默丢失,无告警 ❌ 否决
    无限 requeue(现状) 抛异常就重投 不丢消息 毒消息队头阻塞、日志风暴 ❌ 否决
    有限重试 + DLQ 3 次退避重试 → 死信 瞬时故障可恢复、毒消息隔离、可治理 需配置 DLX/告警/补偿 ✅ 采用
    幂等表(同事务) UNIQUE(event_id, consumer_name) + 同事务副作用 根治重复消费;可对账 多一张表;必须先有 event_id ✅ 采用(核心)
    Redis SETNX 幂等 内存去重 快 与 DB 事务无原子性、丢数据风险 ❌ 权益类不用

    推荐方案 = 有限重试 + DLQ + 同事务幂等表 + 告警补偿。 完整实现如下:

    — ============ 1. 消费幂等表 ============
    CREATE TABLE consume_record (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
    event_id VARCHAR(64) NOT NULL COMMENT '消息事件ID(生产端生成,全局唯一,如 UUID)',
    consumer_name VARCHAR(64) NOT NULL COMMENT '消费者标识:POINT / STOCK / NOTIFY',
    order_id BIGINT NOT NULL COMMENT '业务订单号(便于对账)',
    processed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费完成时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_event_consumer (event_id, consumer_name)
    ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'MQ 消费幂等记录表';

    — 对账查询:某订单的积分消费记录
    — SELECT * FROM consume_record WHERE order_id = ? AND consumer_name = 'POINT';

    // ============ 2. 实体与仓储 ============
    package com.example.point.repo;

    import jakarta.persistence.Column;
    import jakarta.persistence.Entity;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.GenerationType;
    import jakarta.persistence.Id;
    import jakarta.persistence.Table;
    import jakarta.persistence.UniqueConstraint;
    import lombok.AllArgsConstructor;
    import lombok.Builder;
    import lombok.Getter;
    import lombok.NoArgsConstructor;

    import java.time.LocalDateTime;

    @Entity
    @Table(name = "consume_record",
    uniqueConstraints = @UniqueConstraint(
    name = "uk_event_consumer",
    columnNames = {"event_id", "consumer_name"}))
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class ConsumeRecord {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "event_id", nullable = false)
    private String eventId;

    @Column(name = "consumer_name", nullable = false)
    private String consumerName;

    @Column(name = "order_id", nullable = false)
    private Long orderId;

    @Column(name = "processed_at", nullable = false)
    private LocalDateTime processedAt;
    }

    package com.example.point.repo;

    import org.springframework.data.jpa.repository.JpaRepository;

    public interface ConsumeRecordRepository extends JpaRepository<ConsumeRecord, Long> {

    boolean existsByEventIdAndConsumerName(String eventId, String consumerName);
    }

    // ============ 3. 消息体:必须带 event_id(生产端生成) ============
    package com.example.order.event;

    /**
    * 订单创建事件。event_id 在生产端生成(UUID),全局唯一。
    * 为什么必须在消息里带 event_id?——消费者幂等的"键"必须由消息自身携带,
    * 不能由消费者自行推断(如用 orderId 推断,多个事件会互相撞)。
    */

    public record OrderCreatedMessage(
    String eventId, // 全局唯一事件 ID
    Long orderId,
    Long userId,
    Integer amount
    ) {
    public static OrderCreatedMessage of(Long orderId, Long userId, Integer amount) {
    return new OrderCreatedMessage(java.util.UUID.randomUUID().toString(),
    orderId, userId, amount);
    }
    }

    // ============ 4. 积分服务:同事务幂等加积分(核心修复) ============
    package com.example.point.service;

    import com.example.order.event.OrderCreatedMessage;
    import com.example.point.repo.ConsumeRecord;
    import com.example.point.repo.ConsumeRecordRepository;
    import com.example.point.repo.UserPointRepository;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.jdbc.core.JdbcTemplate;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;

    import java.time.LocalDateTime;

    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class PointService {

    public static final String CONSUMER_NAME = "POINT";

    private final ConsumeRecordRepository consumeRecordRepository;
    private final UserPointRepository userPointRepository;
    private final JdbcTemplate jdbcTemplate;

    /**
    * 幂等加积分:消费记录与积分变更必须在【同一个数据库事务】里。
    *
    * 为什么必须同事务?(三个为什么)
    * ① 为什么先抢唯一键?—— 唯一键是"并发重复"的最后防线:
    * 两个消费者同时处理同一消息,只有一个能 INSERT 成功;
    * ② 为什么用 INSERT IGNORE 而不是先 exists 再 insert?
    * —— exists→insert 两步之间存在竞态窗口;INSERT IGNORE 一步完成、
    * 且不抛异常、不污染当前事务(JPA 的 flush 抛 DuplicateKeyException
    * 会把事务标记为 rollback-only,同事务内再操作就不可靠了);
    * ③ 为什么"记录 + 积分"必须同事务?
    * —— 若记录提交了而积分失败,重投时撞唯一键直接跳过 → 积分永久丢失;
    * 同事务则"记录失败 ⇒ 积分一起回滚 ⇒ 重投重新执行",不丢不重。
    */

    @Transactional
    public void addPointsIdempotent(OrderCreatedMessage message) {
    // 快速路径:已消费过(多数重复投递走这里,避免无谓的写)
    if (consumeRecordRepository.existsByEventIdAndConsumerName(
    message.eventId(), CONSUMER_NAME)) {
    log.info("duplicate message ignored: eventId={}, orderId={}",
    message.eventId(), message.orderId());
    return;
    }

    // 抢唯一键:成功 → 返回 1;撞键(并发重复)→ 返回 0,不抛异常
    int inserted = jdbcTemplate.update("""
    INSERT IGNORE INTO consume_record
    (event_id, consumer_name, order_id, processed_at)
    VALUES (?, ?, ?, ?)
    """
    ,
    message.eventId(), CONSUMER_NAME, message.orderId(),
    LocalDateTime.now());

    if (inserted == 0) {
    log.info("unique key conflict, already consumed: eventId={}",
    message.eventId());
    return; // 并发重复:另一个事务已消费,本事务无副作用,正常返回
    }

    // 唯一键抢到:执行真正的业务副作用(与上面的 INSERT 同事务)
    userPointRepository.addPoints(message.userId(), message.amount());
    log.info("points added: userId={}, amount={}, eventId={}",
    message.userId(), message.amount(), message.eventId());
    }
    }

    // ============ 5. 消费者:不再吞异常,交给重试/DLQ 机制 ============
    package com.example.point.consumer;

    import com.example.order.event.OrderCreatedMessage;
    import com.example.point.service.PointService;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.amqp.rabbit.annotation.RabbitListener;
    import org.springframework.stereotype.Component;

    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class PointConsumer {

    private final PointService pointService;

    /**
    * 幂等消费:重复投递只生效一次。
    * 方法正常返回 → Spring 自动 ack → 消息出队;
    * 方法抛异常 → 容器按配置重试(max-attempts=3)→ 耗尽后拒绝进 DLQ。
    * 绝不 catch 业务异常!——吞异常 = 主动丢消息。
    */

    @RabbitListener(queues = "order.created.point")
    public void consume(OrderCreatedMessage message) {
    pointService.addPointsIdempotent(message);
    }
    }

    # ============ 6. 配置:有限重试 + 拒绝进 DLQ(Spring Boot 托管方式) ============
    spring:
    rabbitmq:
    listener:
    simple:
    acknowledge-mode: auto # 方法正常返回即 ack;抛异常走重试
    retry:
    enabled: true # 开启消费重试
    max-attempts: 3 # 最多 3 次(1 次原始 + 2 次重试)
    initial-interval: 1000ms # 首次重试间隔
    multiplier: 2.0 # 指数退避:1s → 2s
    max-interval: 10000ms
    default-requeue-rejected: false # 关键:重试耗尽后拒绝并投递到 DLQ,
    # 而不是无限 requeue 回原队列

    // ============ 7.(等价显式写法)容器工厂 + 队列声明 DLX/DLQ ============
    package com.example.point.config;

    import org.springframework.amqp.core.Binding;
    import org.springframework.amqp.core.BindingBuilder;
    import org.springframework.amqp.core.DirectExchange;
    import org.springframework.amqp.core.Queue;
    import org.springframework.amqp.core.QueueBuilder;
    import org.springframework.amqp.rabbit.config.RetryInterceptorBuilder;
    import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
    import org.springframework.amqp.rabbit.connection.ConnectionFactory;
    import org.springframework.amqp.rabbit.core.RabbitTemplate;
    import org.springframework.amqp.rabbit.retry.RejectAndDontRequeueRecoverer;
    import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;

    /**
    * 死信链路:
    * order.created.point(业务队列)
    * │ 处理失败重试 3 次
    * ▼
    * RejectAndDontRequeueRecoverer → 拒绝消息
    * │ x-dead-letter-exchange 路由
    * ▼
    * order.created.point.dlq(死信队列)→ 落库 + 告警 + 人工/补偿
    */

    @Configuration
    public class RabbitConfig {

    public static final String POINT_QUEUE = "order.created.point";
    public static final String POINT_DLQ = "order.created.point.dlq";
    public static final String DLX = "order.dead-letter.exchange";

    @Bean
    public DirectExchange deadLetterExchange() {
    return new DirectExchange(DLX, true, false);
    }

    @Bean
    public Queue pointQueue() {
    return QueueBuilder.durable(POINT_QUEUE)
    .deadLetterExchange(DLX) // 拒绝/过期/超长 → 进死信
    .deadLetterRoutingKey(POINT_DLQ)
    .build();
    }

    @Bean
    public Queue pointDlq() {
    return QueueBuilder.durable(POINT_DLQ).build();
    }

    @Bean
    public Binding dlqBinding() {
    return BindingBuilder.bind(pointDlq()).to(deadLetterExchange()).with(POINT_DLQ);
    }

    @Bean
    public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
    ConnectionFactory connectionFactory) {
    SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
    factory.setConnectionFactory(connectionFactory);
    factory.setAcknowledgeMode(org.springframework.amqp.core.AcknowledgeMode.AUTO);
    factory.setDefaultRequeueRejected(false); // 重试耗尽不再回原队列
    factory.setMessageConverter(new Jackson2JsonMessageConverter()); // JSON 消息体
    // 有限重试 + 指数退避;耗尽后 RejectAndDontRequeueRecoverer 拒绝消息 → 走 DLX
    factory.setAdviceChain(RetryInterceptorBuilder.stateless()
    .maxAttempts(3)
    .backOffOptions(1000L, 2.0, 10000L)
    .recoverer(new RejectAndDontRequeueRecoverer())
    .build());
    return factory;
    }
    }

    // ============ 8. 死信消费者:落库 + 告警(DLQ 的治理职责) ============
    package com.example.point.consumer;

    import com.example.order.event.OrderCreatedMessage;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.amqp.rabbit.annotation.RabbitListener;
    import org.springframework.stereotype.Component;

    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class PointDlqConsumer {

    private final DeadLetterRecordRepository deadLetterRecordRepository;
    private final AlertService alertService;

    /**
    * DLQ 消费者:不重试(毒消息重试无意义),只做三件事:
    * ① 落库留痕(错误明细,供对账与重放);
    * ② 告警(值班群/监控平台);
    * ③ 等待人工修复根因后,由补偿工具重投或直接补偿。
    */

    @RabbitListener(queues = RabbitConfig.POINT_DLQ)
    public void onDeadLetter(OrderCreatedMessage message) {
    deadLetterRecordRepository.save(DeadLetterRecord.from(message, CONSUMER_POINT));
    alertService.alert("积分消费进入死信队列: eventId=" + message.eventId());
    log.error("dead letter received: eventId={}, orderId={}",
    message.eventId(), message.orderId());
    }
    }

    补充:如果业务要求"不丢任何一条",可再加一层对账 Job:定时扫描"订单已创建但 consume_record 缺失"的订单(以 order 表为准),把漏消费的事件重新投递。幂等表 + 对账 = 双保险。

    🧪 需要补充的测试

    测试目的关键点
    重复投递幂等测试 同一消息投递 3 次,积分只 +100 一次 模拟 connection broken 后的重投
    并发重复测试 两线程同时消费同一消息,唯一键只放行一个 断言积分只加一次
    同事务测试 加积分失败 → 记录一起回滚 → 重投可重新执行 断言不丢不重
    吞异常回归测试 证明"catch 吞异常"会导致消息丢失 展示错误方案的危害
    毒消息进 DLQ 测试 持续抛异常的消息 3 次后进 DLQ,不阻塞正常队列 断言 DLQ 收到消息、原队列继续消费

    package com.example.point.service;

    import com.example.order.event.OrderCreatedMessage;
    import com.example.point.repo.ConsumeRecord;
    import com.example.point.repo.ConsumeRecordRepository;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.dao.DuplicateKeyException;
    import org.springframework.jdbc.core.JdbcTemplate;
    import org.springframework.transaction.support.TransactionTemplate;

    import static org.assertj.core.api.Assertions.assertThat;

    @SpringBootTest
    class PointServiceIdempotencyTest {

    @Autowired PointService pointService;
    @Autowired ConsumeRecordRepository consumeRecordRepository;
    @Autowired JdbcTemplate jdbcTemplate;
    @Autowired TransactionTemplate transactionTemplate;

    private final OrderCreatedMessage msg =
    OrderCreatedMessage.of(1001L, 7L, 100);

    @Test
    void duplicateDelivery_pointsAddedOnlyOnce() {
    // 模拟 at-least-once 重复投递:同一消息消费 3 次
    pointService.addPointsIdempotent(msg);
    pointService.addPointsIdempotent(msg);
    pointService.addPointsIdempotent(msg);

    Integer points = jdbcTemplate.queryForObject(
    "SELECT points FROM user_point WHERE user_id = 7", Integer.class);
    assertThat(points).isEqualTo(100); // 只加了 100,不是 300

    assertThat(consumeRecordRepository
    .existsByEventIdAndConsumerName(msg.eventId(), "POINT")).isTrue();
    }

    @Test
    void concurrentDuplicate_onlyOneInsertWins() throws Exception {
    // 两线程并发消费同一消息:唯一键保证只有一个生效
    java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1);
    java.util.concurrent.ExecutorService pool =
    java.util.concurrent.Executors.newFixedThreadPool(2);
    java.util.concurrent.atomic.AtomicInteger exceptions = new java.util.concurrent.atomic.AtomicInteger();
    for (int i = 0; i < 2; i++) {
    pool.submit(() -> {
    try {
    start.await();
    pointService.addPointsIdempotent(msg);
    } catch (Exception e) {
    exceptions.incrementAndGet();
    }
    });
    }
    start.countDown();
    pool.shutdown();
    pool.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS);

    Integer points = jdbcTemplate.queryForObject(
    "SELECT points FROM user_point WHERE user_id = 7", Integer.class);
    assertThat(points).isEqualTo(100); // 并发也只加一次
    assertThat(exceptions.get()).isEqualTo(0); // 且没有任何异常(INSERT IGNORE 不抛)
    }

    @Test
    void addPointsFailure_rollsBackRecordTogether() {
    // 同事务验证:加积分失败(模拟 user_id 不存在的外键/除零)→
    // 消费记录必须一起回滚 → 重投后还能重新执行(不丢)
    transactionTemplate.executeWithoutResult(status -> {
    try {
    pointService.addPointsIdempotent(
    new OrderCreatedMessage("bad-event-1", 999L, –1L, 100));
    } catch (Exception expected) {
    // 预期失败
    }
    });
    // 记录随事务回滚:不存在
    assertThat(consumeRecordRepository
    .existsByEventIdAndConsumerName("bad-event-1", "POINT")).isFalse();
    }
    }

    💡 工程要点(教学总结)

    • at-least-once 是 MQ 的默认现实,重复消费不是 bug 而是预期;所有消费者都要回答一个问题:“这条消息收到两次,业务会怎样?”——答不上来就是 P0。
    • 吞异常 = 主动丢消息:消费者代码里唯一允许吞的异常是"幂等冲突";其余异常交给"有限重试 → DLQ → 告警补偿"。
    • 幂等三要素:唯一的键(event_id,生产端生成)、持久化的去重表(UNIQUE(event_id, consumer_name))、与业务副作用同事务——三者缺一,幂等就不成立。
    • retry 必须有界、有界之后必须分流(DLQ)、分流之后必须治理(告警+补偿):无限 requeue 是毒消息的放大器,DLQ 不是垃圾箱而是治理入口。
    • 先判断业务性质:资金/权益类必须 DB 事务幂等(可对账),日志/埋点类才允许 Redis 去重或 at-most-once。
    • 可观测性:消费积压、重试次数、DLQ 深度都要有指标告警——问题要在用户投诉之前被发现。

    题 4:异步任务线程池(newCachedThreadPool 失控 / 有界线程池 / Rate Limit / Bulkhead / 异步 Job 设计)

    🚩 审查 Issue(结论先行)

    问题位置风险等级问题类型触发条件可能后果
    AsyncConfig:Executors.newCachedThreadPool()(L767-775) P0(资源失控,线上已宕机) 无界线程池(资源边界缺失) 流量上涨:300 req/s × AI 平均 4s/P95 25s → 任务并发数上千 线程数无上限增长 → 内存↑(每线程栈)→ CPU 上下文切换↑ → 内存压力 → Full GC → 整个 JVM 无响应(线上现象)
    generateReport():@Async + 无返回值、无状态、无恢复(L786-799) P1(可靠性缺陷) 异步任务无状态管理 任务执行中 JVM 重启/宕机;AI 调用失败 任务静默丢失:用户点了"生成"永远等不到结果,且无查询入口
    AI 调用无超时、无重试、无熔断(L793) P1(依赖故障放大) 下游故障无防护 AI Provider 变慢/故障 60s+ 的僵尸调用堆积,进一步放大线程池压力;故障恢复后无重试,任务直接失败
    开发人员提出的 Executors.newFixedThreadPool(1000)(L837) P1(错误解法,治标不治本) 资源边界理解错误 照此实施 1000 线程对 50 并发下游毫无意义;仍无队列/拒绝策略/背压;高峰期同样会内存与切换开销失控

    结论先行:这道题的核心不是"换一个线程池实现",而是重新理解资源边界——"异步"只是把工作从请求线程挪到后台线程,总资源(CPU/内存/下游并发能力)没有增加一分一毫。正确链路 = 有界线程池(数量由下游并发上限决定) + 有界队列(背压) + 拒绝策略 + 超时/重试/熔断 + 持久化 Job 状态机(失败可恢复)。几十秒级的 AI Job 根本不该用 @Async 裸跑。

    📖 深入讲解(为什么有问题)

    1. newCachedThreadPool() 最大的问题是什么?

    线程数无上限(unbounded),且无背压(backpressure)。 逐层拆解:

    • 机制:newCachedThreadPool = SynchronousQueue + 线程数无上限(Integer.MAX_VALUE)+ 60s keepAlive。SynchronousQueue 是"零容量"队列——提交的任务必须立刻有线程接走,没有线程就现场 new 一个。所以每个未及时处理的任务 = 一个新线程。
    • 为什么线程是昂贵资源:每个线程默认栈 1MB(-Xss)+ 线程对象 + 内核线程;1000 个线程 ≈ 1GB+ 虚拟内存;更重要的是上下文切换——CPU 在 1000 个可运行线程间切换的开销远超计算本身。
    • 为什么会导致 Full GC:线程多了 → 每个线程的栈、局部变量、线程对象都占堆外/堆内存 → 内存压力 → GC 频繁 → 停顿变长 → 任务处理更慢 → 需要更多线程 → 恶性循环(正反馈失控)。最终 JVM 长时间 STW,无响应。
    • 没有背压:SynchronousQueue 不接受排队,意味着系统对"任务涌入速度"没有任何缓冲和限制——流量多大,线程就建多少,直到 OOM。
    • 一句话:cached pool 是为"短命、低频、突发"任务设计的(如临时线程池),不是为"高并发、长耗时"任务设计的。
    2. 为什么"异步"并不等于"系统吞吐能力无限"?

    因为异步只是重新分配了线程,没有增加任何物理资源。做一件工作的资源消耗是恒定的:CPU 计算、内存、网络连接、下游服务能力。异步把"请求线程"换成"后台线程",收益是请求线程被释放、接口延迟下降,代价是后台线程池成为新的瓶颈。吞吐上限 = min(CPU 能力, 内存能力, 下游并发能力, 连接池大小, 线程数)。本题里,真正的天花板是 AI Provider 的 50 并发——系统再快,下游只接受 50 个并发请求,其余请求无论开多少线程都只能排队等待。异步改变了"谁在等",不改变"有多少工作在等"。

    3. 固定 1000 个线程是不是解决方案?

    不是,而且方向就错了。 四个理由:

  • 与下游能力脱节:AI Provider 只允许 50 并发,开 1000 线程意味着 950 个线程阻塞等待下游——纯浪费(线程栈内存 + 上下文切换 + 无产出);
  • 没有队列 = 没有背压:fixed pool 用的是 LinkedBlockingQueue(无界)——线程满了任务进无界队列,内存照样被任务对象撑爆,只是把"线程爆炸"换成"队列爆炸";
  • 没有拒绝策略:即使有界队列 + max 满,默认 AbortPolicy 直接抛 RejectedExecutionException,任务丢失且无补偿;
  • 1000 本身没有依据:线程数应该由"下游并发上限 + 本机 CPU 核数 + IO 密集程度"推导,而不是拍脑袋。线程数是资源预算,不是吞吐开关。
  • 4. AI HTTP 调用属于 CPU 密集还是 IO 密集?

    IO 密集(阻塞式等待网络响应)。 判断标准:任务的大部分时间花在"等待"(网络往返、磁盘、下游处理)还是"计算"(CPU 执行指令)?AI 调用发出 HTTP 请求后,线程就阻塞在 socket 上等响应(平均 4s、P95 25s),CPU 几乎空闲。所以:

    • IO 密集任务的线程数可以 > CPU 核数(经验公式:线程数 ≈ CPU 核数 × (1 + 等待时间/计算时间),等待占比越高,线程数可以越大);
    • 但上限必须由下游并发能力决定:AI Provider 50 并发 → 本服务对 AI 的并发调用最多 50,线程池 maxPoolSize 取 50 左右即可,多出来的线程只是陪跑;
    • 对比:CPU 密集任务(图像处理、加解密)线程数 ≈ CPU 核数 + 1,多了反而因上下文切换变慢。
    5. 线程池五大参数分别控制什么?
    参数控制什么本题取值逻辑
    corePoolSize 常驻线程数(即使空闲也保留) 10~20:保证平时任务无需建线程;常驻线程越多,空闲成本越高
    maxPoolSize 线程数上限(核心满 + 队列满后才扩容到 max) ≈ 下游并发上限(50):超过 50 的并发对 AI 无意义
    queueCapacity 队列缓冲长度(背压的容量) 200:允许短暂高峰排队;有界才有背压
    keepAlive 非核心线程空闲回收时间 60s:高峰过后回收多余线程,防止长期占资源
    RejectedExecutionHandler 队列满 + 线程满时怎么办(最后的防线) CallerRunsPolicy:由提交线程(Tomcat worker)执行 → 请求自然变慢 → 背压传导给调用方;或自定义"持久化任务待重试"

    关键机制:ThreadPoolExecutor 的扩容顺序是"core 满 → 入队 → 队列满 → 扩到 max → 队列满且线程满 → 拒绝策略"。 所以 core=10, max=50, queue=200 的真实含义是:最多 50 个任务同时执行,200 个在队列等待,第 251 个触发拒绝策略——整个系统对任务的容纳有明确上限(250),这就是"有界"。

    6. 300 req/s × 4s,系统大约同时存在多少任务?

    用 Little’s Law(利特尔法则):L = λ × W(系统中任务数 = 到达率 × 平均驻留时间)。

    • 平均:L = 300/s × 4s = 1200 个任务同时存在;
    • 最坏(P95):L = 300/s × 25s = 7500 个;
    • 极端(60s):L = 300/s × 60s = 18000 个!

    这就是 newCachedThreadPool 会创建的线程数量级:高峰期系统里同时躺着几千个任务,cached pool 就给你建几千个线程 → 内存/切换/GC 全线崩溃。这个数字也直接否定了"固定 1000 线程":1000 线程连平均并发(1200)都兜不住,而且兜住了也没用——下游只有 50 并发。正确姿态:接受"排队",用有界队列缓冲,用拒绝策略把压力传导回去,而不是试图用线程数硬扛。

    7. AI Provider 50 并发限制下,本服务线程池开 1000 有什么意义?

    没有任何正面意义,只有负面代价。 分析:

    • 50 并发限制意味着任何时刻最多 50 个 HTTP 请求能真正发出,其余 950 个线程全部阻塞在"等待下游配额/等待响应"上——它们不产生任何吞吐;
    • 代价却实打实:950 个线程的栈内存、950 个线程的上下文切换(调度器要在它们之间来回切换)、950 个等待中的连接/对象;
    • 更危险的是超时重试风暴:1000 个线程同时超时重试,会把下游 50 并发的限流打到熔断,雪崩。

    正确对齐:本服务线程池并发上限 ≈ 下游并发上限(50),多出的任务在队列里等(背压),而不是开线程等(浪费)。这就是 Bulkhead(舱壁隔离)思想:线程池的容量 = 你对下游的承诺,超出就排队/拒绝,绝不透支。

    8. 几十秒甚至几分钟的 AI Job,适不适合直接使用 @Async?

    不适合。 判断标准:任务生命周期是否超过"进程可靠性"的范围。

    维度@Async 适合(短任务)Job 系统适合(长任务)
    任务时长 秒级以内(发邮件、写日志、预热缓存) 几十秒~几分钟(AI 生成、报表、批处理)
    状态 无状态,失败可丢可重发 必须持久化状态(PENDING/RUNNING/SUCCESS/FAILED),用户可查询
    进程重启 任务丢失可接受 任务丢失不可接受(用户等了很久)
    失败恢复 无恢复机制 重启后扫描未完成任务重新入队
    可见性 无查询入口 有查询接口 + 重试接口

    为什么 @Async 撑不住长任务?——@Async 的本质是"丢进线程池就不管了":没有返回值/状态存储、JVM 重启任务蒸发、失败没有补偿入口、线程池是内存态(重启即清空)。而几十秒的 AI Job,用户必然会问"我的报告生成到哪一步了?"——这要求任务有持久化状态机。所以正确链路是:HTTP 提交 → 落 Job 表(状态机)→ 有界执行器调度 → 更新状态 → 用户轮询查询。@Async 只适合作为"Job 执行器内部"的短任务载体,或者干脆不要 @Async,用显式 executor.execute()。

    🛠 解决方案

    候选方案对比 → 推荐方案:

    方案优点缺点结论
    newCachedThreadPool(现状) 简单 无界线程、无背压、OOM ❌ 否决
    newFixedThreadPool(1000)(提案) 有上限 上限错误(与下游脱节)、无界队列无背压、无拒绝策略 ❌ 否决
    有界线程池 + 有界队列 + 拒绝策略 有界 = 背压;线程数对齐下游 高峰期会拒绝/排队,需要配合状态机 ✅ 采用(执行层)
    Job 表状态机 + 有界执行器 + Bulkhead + 超时/重试/熔断 + 恢复调度 长任务可查询、可恢复、可重试;资源边界完整 实现工作量较大(一张表 + 状态机 + 调度) ✅ 采用(总链路,推荐)

    推荐链路设计:

    POST /reports/{id}/generate
    ↓ ① 创建 Job(落库:PENDING)
    ↓ ② dispatch:提交到有界线程池
    ↓ ③ 线程池(core=10, max=50, queue=200, CallerRuns)── 资源边界
    ↓ ④ Bulkhead:Semaphore(50) 与 AI Provider 对齐 ──────── 下游配额
    ↓ ⑤ Timeout:65s(> P95 25s,覆盖极端 60s)──────────── 僵尸调用防护
    ↓ ⑥ Retry:指数退避 1s→2s,最多 3 次 ────────────────── 瞬时故障恢复
    ↓ ⑦ Circuit Breaker:AI 连续失败 → 快速失败不重试 ───── 依赖故障防护
    ↓ ⑧ 更新 Job 状态(SUCCESS/FAILED),失败可人工重试
    ↓
    AI Provider(50 concurrent)

    配套:GET /api/reports/jobs/{jobId} 查询状态;定时 recoverStuckJobs 扫描 PENDING 超时/RUNNING 超时任务重新入队(失败恢复)。

    // ============ 1. 有界线程池(替代 newCachedThreadPool / fixed 1000) ============
    package com.example.report.config;

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.scheduling.annotation.EnableAsync;
    import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

    import java.util.concurrent.Executor;
    import java.util.concurrent.ThreadPoolExecutor;

    @Configuration
    @EnableAsync
    public class AsyncConfig {

    /**
    * 有界线程池:容量 = 对 AI Provider 的承诺。
    * corePoolSize=10 常驻线程,覆盖日常流量;
    * maxPoolSize=50 = AI Provider 并发上限(Bulkhead 对齐);
    * queueCapacity=200 有界队列 = 背压缓冲(高峰期任务在此排队,而不是开新线程);
    * keepAlive=60s 高峰过后回收非核心线程;
    * CallerRunsPolicy 队列满 + 线程满 → 由 Tomcat 提交线程执行 → 接口自然变慢
    * → 背压传导给调用方,而不是丢弃任务或撑爆内存。
    */

    @Bean("reportExecutor")
    public Executor reportExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(50);
    executor.setQueueCapacity(200);
    executor.setKeepAliveSeconds(60);
    executor.setThreadNamePrefix("report-exec-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();
    return executor;
    }
    }

    — ============ 2. 报告生成 Job 表(持久化状态机) ============
    CREATE TABLE report_job (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Job ID',
    report_id BIGINT NOT NULL COMMENT '报告 ID',
    user_id BIGINT NOT NULL COMMENT '发起用户',
    status VARCHAR(16) NOT NULL COMMENT 'PENDING / RUNNING / SUCCESS / FAILED',
    attempt INT NOT NULL DEFAULT 0 COMMENT '已尝试次数(重试上限)',
    last_error VARCHAR(512) NULL COMMENT '最近一次失败原因',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    started_at DATETIME NULL COMMENT '开始执行时间(用于超时恢复判断)',
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY idx_status_updated (status, updated_at)
    ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'AI 报告生成异步任务表';

    // ============ 3. Job 实体 ============
    package com.example.report.job;

    import jakarta.persistence.Column;
    import jakarta.persistence.Entity;
    import jakarta.persistence.EnumType;
    import jakarta.persistence.Enumerated;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.GenerationType;
    import jakarta.persistence.Id;
    import jakarta.persistence.Table;
    import lombok.AllArgsConstructor;
    import lombok.Builder;
    import lombok.Getter;
    import lombok.NoArgsConstructor;

    import java.time.LocalDateTime;

    @Entity
    @Table(name = "report_job")
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class ReportJob {

    public enum Status { PENDING, RUNNING, SUCCESS, FAILED }

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "report_id", nullable = false)
    private Long reportId;

    @Column(name = "user_id", nullable = false)
    private Long userId;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    private Status status;

    @Column(name = "attempt", nullable = false)
    private Integer attempt;

    @Column(name = "last_error")
    private String lastError;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @Column(name = "started_at")
    private LocalDateTime startedAt;
    }

    // ============ 4. Job 执行服务:状态机 + Bulkhead + 超时 + 重试 ============
    package com.example.report.job;

    import com.example.report.ai.AiClient;
    import com.example.report.repo.ReportRepository;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Qualifier;
    import org.springframework.stereotype.Service;

    import java.time.Duration;
    import java.util.concurrent.CompletableFuture;
    import java.util.concurrent.CompletionException;
    import java.util.concurrent.Executor;
    import java.util.concurrent.Semaphore;
    import java.util.concurrent.TimeUnit;

    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class ReportJobService {

    private static final Duration AI_TIMEOUT = Duration.ofSeconds(65);
    private static final int MAX_ATTEMPTS = 3;

    private final ReportJobRepository jobRepository;
    private final ReportRepository reportRepository;
    private final AiClient aiClient;

    @Qualifier("reportExecutor")
    private final Executor reportExecutor;

    /**
    * Bulkhead(舱壁):与 AI Provider 的 50 并发上限对齐。
    * 信号量保证【本服务对 AI 的并发调用】永远 ≤ 50:
    * – 拿不到配额 → 任务保持 PENDING,等下次调度,而不是开新线程硬挤;
    * – 这是"线程池容量 = 下游承诺"的落地实现。
    */

    private final Semaphore aiConcurrency = new Semaphore(50);

    // —————- 对外入口 —————-

    /** 创建 Job 并提交执行。 */
    public Long createAndDispatch(Long reportId, Long userId) {
    ReportJob job = jobRepository.save(ReportJob.builder()
    .reportId(reportId)
    .userId(userId)
    .status(ReportJob.Status.PENDING)
    .attempt(0)
    .createdAt(java.time.LocalDateTime.now())
    .build());
    dispatch(job.getId());
    return job.getId();
    }

    /** 提交执行:交给有界线程池。 */
    public void dispatch(Long jobId) {
    // 显式 execute 而不是 @Async——任务状态由 Job 表管理,不依赖方法返回值
    reportExecutor.execute(() -> execute(jobId));
    }

    // —————- 执行核心 —————-

    public void execute(Long jobId) {
    ReportJob job = jobRepository.findById(jobId).orElse(null);
    if (job == null || job.getStatus() == ReportJob.Status.SUCCESS) {
    return;
    }
    // ① Bulkhead:拿不到下游配额就不发起调用(任务留在 PENDING,等恢复调度)
    if (!aiConcurrency.tryAcquire(5, TimeUnit.SECONDS)) {
    log.warn("AI bulkhead full, job {} stays pending", jobId);
    return;
    }
    try {
    // ② 状态推进 + 尝试次数递增
    jobRepository.markRunning(jobId,
    job.getAttempt() + 1, java.time.LocalDateTime.now());
    // ③ 超时 + 有限重试
    String result = callAiWithRetry(job);
    // ④ 业务落库 + 状态 SUCCESS
    reportRepository.updateResult(job.getReportId(), result, "SUCCESS");
    jobRepository.markSuccess(jobId);
    } catch (Exception e) {
    log.error("job {} failed: {}", jobId, e.getMessage(), e);
    // ⑤ 失败:标记 FAILED(可人工重试),错误留痕
    jobRepository.markFailed(jobId, truncate(e.getMessage()));
    } finally {
    aiConcurrency.release(); // 释放下游配额
    }
    }

    /**
    * 超时 + 指数退避重试:
    * – Timeout 65s:AI P95=25s、极端 60s,65s 超时能挡住"僵尸调用"又不误杀正常请求;
    * – 最多 3 次,退避 1s→2s:给瞬时故障(网络抖动、Provider 限流)恢复机会;
    * – 为什么超时如此重要?——没有超时,一个 60s+ 的悬挂请求会永远占着
    * 线程 + 下游配额,多来几个就把整个池子堵死。
    */

    private String callAiWithRetry(ReportJob job) throws InterruptedException {
    Exception last = null;
    for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    try {
    return CompletableFuture
    .supplyAsync(() -> aiClient.generate(job.getReportId()))
    .orTimeout(AI_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)
    .join();
    } catch (CompletionException e) {
    last = e;
    log.warn("AI call attempt {}/{} failed for job {}: {}",
    attempt, MAX_ATTEMPTS, job.getId(), e.getCause() != null
    ? e.getCause().getMessage() : e.getMessage());
    if (attempt < MAX_ATTEMPTS) {
    Thread.sleep(1000L * attempt); // 指数退避 1s → 2s
    }
    }
    }
    throw new ReportJobException("AI call failed after " + MAX_ATTEMPTS
    + " attempts", last);
    }

    private String truncate(String s) {
    return s != null && s.length() > 500 ? s.substring(0, 500) : s;
    }
    }

    // ============ 5. Controller:202 受理 + 状态查询 ============
    package com.example.report.web;

    import com.example.report.job.ReportJobService;
    import lombok.RequiredArgsConstructor;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RestController;

    import java.net.URI;

    @RestController
    @RequiredArgsConstructor
    public class ReportController {

    private final ReportJobService reportJobService;

    /**
    * 异步受理:只落 Job + 提交执行,立即返回 202 Accepted + jobId。
    * 客户端拿 jobId 轮询 /api/reports/jobs/{jobId} 获取进度。
    */

    @PostMapping("/reports/{id}/generate")
    public ResponseEntity<GenerateResponse> generate(@PathVariable Long id) {
    Long userId = CurrentUser.id(); // 服务端可信身份
    Long jobId = reportJobService.createAndDispatch(id, userId);
    return ResponseEntity.accepted()
    .location(URI.create("/api/reports/jobs/" + jobId))
    .body(new GenerateResponse(jobId));
    }

    @GetMapping("/api/reports/jobs/{jobId}")
    public ResponseEntity<JobView> jobStatus(@PathVariable Long jobId) {
    return ResponseEntity.ok(reportJobService.view(jobId));
    }
    }

    record GenerateResponse(Long jobId) {}

    record JobView(Long jobId, String status, String lastError, Integer attempt) {}

    // ============ 6. 失败恢复调度:重启/定时扫描卡住的任务 ============
    package com.example.report.job;

    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.scheduling.annotation.Scheduled;
    import org.springframework.stereotype.Component;

    import java.time.LocalDateTime;
    import java.util.List;

    /**
    * 失败恢复:
    * ① PENDING 超过 1 分钟未被消费(线程池拒绝/进程重启)→ 重新入队;
    * ② RUNNING 超过 2 分钟(> AI 超时上限,判定执行线程已死/悬挂)→ 重新入队;
    * ③ FAILED 由用户手动重试(提供重试接口)或人工排查。
    * 为什么必须有?——@Async 时代任务随进程蒸发;Job 表 + 扫描 = 进程重启不丢任务。
    */

    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class JobRecoveryScheduler {

    private final ReportJobRepository jobRepository;
    private final ReportJobService reportJobService;

    @Scheduled(fixedDelay = 60_000)
    public void recoverStuckJobs() {
    LocalDateTime now = LocalDateTime.now();

    List<ReportJob> stuckPending = jobRepository
    .findByStatusAndCreatedAtBefore(ReportJob.Status.PENDING,
    now.minusMinutes(1));
    stuckPending.forEach(j -> reportJobService.dispatch(j.getId()));

    List<ReportJob> stuckRunning = jobRepository
    .findByStatusAndStartedAtBefore(ReportJob.Status.RUNNING,
    now.minusMinutes(2));
    stuckRunning.forEach(j -> {
    log.warn("re-dispatch stuck running job {}", j.getId());
    reportJobService.dispatch(j.getId());
    });

    if (!stuckPending.isEmpty() || !stuckRunning.isEmpty()) {
    log.info("recovery scan: {} pending, {} running re-dispatched",
    stuckPending.size(), stuckRunning.size());
    }
    }
    }

    // ============ 7.(进阶)熔断:AI 连续失败快速失败,不再空耗重试 ============
    package com.example.report.ai;

    import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
    import org.springframework.stereotype.Component;

    /**
    * Circuit Breaker(熔断器):AI Provider 连续失败达到阈值 →
    * 状态变为 OPEN,后续请求快速失败(fallback),不再发起注定失败的调用;
    * 经过冷却时间后半开试探,恢复则 CLOSE。
    * 为什么需要?——重试解决"瞬时故障",熔断解决"持续故障":
    * 持续故障下重试只会放大下游压力,熔断把伤害隔离在本服务内。
    */

    @Component
    public class AiClientWithBreaker {

    private final AiClient aiClient;

    @CircuitBreaker(name = "aiProvider", fallbackMethod = "aiFallback")
    public String generate(Long reportId) {
    return aiClient.generate(reportId);
    }

    public String aiFallback(Long reportId, Throwable t) {
    throw new AiProviderUnavailableException("AI provider unavailable", t);
    }
    }

    # ============ 8. AI 客户端超时(WebClient 配置:连接/读取超时) ============
    spring:
    webclient:
    # 示例:WebClient 的响应超时必须在构建时设置,这里给出概念性配置位置
    # 真实代码:WebClient.builder()
    # .clientConnector(new ReactorClientHttpConnector(
    # HttpClient.create().responseTimeout(Duration.ofSeconds(65))))
    # .build();
    connect-timeout: 5s

    🧪 需要补充的测试

    测试目的关键点
    并发任务数估算 用 Little’s Law 验证 300req/s×4s ≈ 1200 并发 面试/设计评审直接口算,无需代码
    拒绝策略测试 提交超过容量的任务 → CallerRuns 不抛异常、不丢任务 断言任务全部执行完或由调用线程执行
    超时测试 Mock AI 挂起 70s → 65s 超时触发 断言不无限等待
    Bulkhead 测试 50 并发信号量 → 第 51 个任务不发起调用 断言并发调用数 ≤ 50
    Job 状态机测试 PENDING→RUNNING→SUCCESS/FAILED 流转正确 断言状态与重试次数
    恢复调度测试 模拟进程重启后 PENDING 任务被重新调度 断言任务最终完成

    package com.example.report.job;

    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;

    import java.util.concurrent.CountDownLatch;
    import java.util.concurrent.Executor;
    import java.util.concurrent.TimeUnit;
    import java.util.concurrent.atomic.AtomicInteger;

    import static org.assertj.core.api.Assertions.assertThat;

    @SpringBootTest
    class ReportJobServiceTest {

    @Autowired ReportJobService reportJobService;

    @Autowired
    @org.springframework.beans.factory.annotation.Qualifier("reportExecutor")
    Executor reportExecutor;

    @Test
    void boundedExecutor_neverCreatesUnboundedThreads() throws Exception {
    // 提交远超容量的短任务,验证:不 OOM、不抛 RejectedExecutionException(CallerRuns 兜底)、全部完成
    int tasks = 5000;
    AtomicInteger done = new AtomicInteger();
    CountDownLatch latch = new CountDownLatch(tasks);
    for (int i = 0; i < tasks; i++) {
    reportExecutor.execute(() -> {
    try {
    Thread.sleep(1);
    } catch (InterruptedException ignored) {
    Thread.currentThread().interrupt();
    }
    done.incrementAndGet();
    latch.countDown();
    });
    }
    assertThat(latch.await(60, TimeUnit.SECONDS)).isTrue();
    assertThat(done.get()).isEqualTo(tasks); // 任务一个不丢(有队列 + CallerRuns 背压)
    }

    @Test
    void bulkhead_neverExceedsDownstreamConcurrency() throws Exception {
    // 模拟:Bulkhead 信号量保证对 AI 的并发调用 ≤ 50
    // 真实实现里 aiConcurrency 是 private;此处通过并发提交 Job 并统计
    // "同时处于 RUNNING 的 Job 数" 来断言 ≤ 50(集成场景)
    int jobs = 200;
    for (int i = 0; i < jobs; i++) {
    reportJobService.dispatch(reportJobService.createAndDispatch(1000L + i, 1L));
    }
    // 轮询 report_job 表,统计 RUNNING 峰值 ≤ 50(此处仅示意断言口径)
    // assertThat(maxRunning).isLessThanOrEqualTo(50);
    }
    }

    💡 工程要点(教学总结)

    • 线程池必须有界,有界 = 背压:无界线程(cached)和无界队列都是"把问题推迟到 OOM";有界队列 + 拒绝策略让系统在容量边界上优雅降速而不是崩溃。
    • 线程数由下游决定,不由流量决定:本服务线程池容量 ≈ 下游并发上限(50),多出的任务排队等配额,绝不开线程陪跑——Bulkhead 思想:给每个下游一个独立的资源舱。
    • 算并发用 Little’s Law:L = λ × W,300req/s × 4s ≈ 1200 并发任务——这个数字在任何异步方案讨论前都应该先算出来,它是资源预算的起点。
    • 长任务(几十秒~分钟级)不用 @Async:@Async 无状态、无持久化、无恢复;用 Job 表状态机 + 有界执行器 + 失败恢复调度,让任务可查询、可重试、可恢复。
    • 下游故障四件套:Timeout(僵尸调用防护)、Retry(瞬时故障恢复)、Circuit Breaker(持续故障隔离)、Rate Limit/Bulkhead(配额对齐)——一个都不能少。
    • 超时是异步系统的第一公民:没有超时的异步调用 = 线程 + 配额 + 队列被无限占用,比同步还危险。

    总结:四条边界 × 六级追问(今日隐藏主线)

    今天的四道题,本质是同一套审查框架的四个应用:

    边界题目核心问题审查答案
    一致性边界 题1 缓存 两套存储无事务,如何保证不读到脏数据? 时序(afterCommit 后删)+ 重试(删除失败补偿)+ 合并(single-flight 防脏写回)+ 有界(TTL 短且随机)
    信任边界 题2 文件下载 客户端输入能控制什么? 一切客户端字段默认不可信;只传引用(fileId)不传位置(path);鉴权归属服务端做
    投递语义边界 题3 MQ 消息到底投递几次? 默认 at-least-once;重复是常态;幂等(同事务)是消费者第一原则;吞异常 = 丢消息
    资源边界 题4 线程池 系统最多能同时扛多少任务? 有界 = 背压;线程数由下游决定;Little’s Law 先算并发再定容量;长任务用 Job 状态机

    六级追问清单(任何 Code Review 都按这个顺序过一遍):

  • 正常情况能跑吗?——能跑只是及格线(四题的原代码都能跑);
  • 异常情况会怎样?——Redis delete 失败?AI 超时?DB 抖动?(题1 脏缓存、题4 悬挂线程);
  • 并发情况下会怎样?——A miss/B update 交错?两个消费者撞同一消息?(题1 脏写回、题3 重复积分);
  • 依赖故障会怎样?——AI Provider 挂 10 分钟?RabbitMQ 断连?(题4 无熔断、题3 ACK 丢失);
  • 流量扩大 100 倍会怎样?——300 req/s 变 30000?cached pool 线程爆炸、明星用户击穿(题4、题1);
  • 失败以后能不能恢复?——进程重启任务还在吗?死信有人处理吗?删除失败有补偿吗?(题4 Job 恢复、题3 DLQ、题1 MQ 补偿)。
  • 一句话收尾:代码审查不是看代码"对不对",而是看代码在"边界被打破"时"死得是否体面、能否复活"。 今天四道题,练的就是这件事。

    赞(0)
    未经允许不得转载:171主机测评 » Day 2 · Java 后端工程代码审查 — 深入审查 Issue 与解决方案教学文
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址