跳至主要內容

十万个 why 选题 — 反直觉行为揭秘方向(20260628)

程序员小富大约 11 分钟

十万个 why 选题 — 反直觉行为揭秘方向(20260628)

选题公式:"明明做了 A,为什么结果却是 B?" — 描述一个开发者日常工作中能遇到的、看起来不应该发生的具体现象,文章揭示背后的技术机制。标题要具体到能复现的场景,不要抽象的架构概念。


Spring / Spring Boot 方向

1. 十万个why:明明 @Async 方法抛了异常,为什么主线程一点反应都没有,日志里也找不到报错?

  • 现象:异步方法执行报错了,但调用方不知道、控制台也没打印异常
  • 根因:@Async 返回 void 时异常被 SimpleAsyncUncaughtExceptionHandler 默默吞掉了,默认只打一行 WARN 级别的日志。返回 Future 时异常要调 .get() 才会抛出。大部分人不知道需要自定义 AsyncExceptionHandler

2. 十万个why:Bean 明明注入成功了,调用方法却报 NullPointerException,DEBUG 发现对象不是 null?

  • 现象:@Autowired 的对象不是 null,但调用它的方法时 NPE
  • 根因:注入的是 CGLIB 代理对象,代理对象的字段都是 null。如果你在代理对象上直接访问了 field(而不是通过 getter),拿到的就是 null。或者在构造函数里使用了注入的 Bean,这时 Bean 还没注入完毕

3. 十万个why:Spring Boot 启动后内存直接飙到 500MB,明明只是一个简单的 CRUD 项目?

  • 现象:pom.xml 里引了一堆 starter,启动后堆内存占用远超预期
  • 根因:每引一个 starter 都会触发自动配置,创建大量你用不到的 Bean(数据源连接池、线程池、监控端点)。spring-boot-starter-web 引入了完整的 Tomcat 容器,spring-boot-starter-data-redis 初始化了 Lettuce 连接池。可以用 spring.autoconfigure.exclude 排除不需要的自动配置,或者用 spring-boot-starter-parent 的 BOM 精确控制依赖

4. 十万个why:接口响应正常返回 200,但客户端拿到的 JSON 里日期字段少了 8 小时?

  • 现象:数据库存的是北京时间,接口返回的 JSON 里变成了 UTC 时间
  • 根因:Jackson 默认用 UTC 序列化 Date/LocalDateTime,跟 JVM 的 TimeZone 和 MySQL 连接的 serverTimezone 三者不一致时就会出偏差。这个坑几乎每个新项目都会踩一次。需要在 application.yml 里同时配 Jackson 的 time-zone、MySQL URL 的 serverTimezone、和 JVM 启动参数 -Duser.timezone

MySQL / 数据库方向

5. 十万个why:明明加了唯一索引防重复插入,为什么高并发下还是插进去了两条一样的数据?

  • 现象:unique key 约束存在,但并发 INSERT 仍然出现了重复记录
  • 根因:如果用了 INSERT ... ON DUPLICATE KEY UPDATE,在 RR 隔离级别下并发插入可能触发间隙锁死锁然后重试成功。更常见的是:唯一索引定义的列组合不对,或者某一列有 NULL 值(MySQL 的 UNIQUE 约束认为 NULL ≠ NULL,所以两条 NULL 不算重复)

6. 十万个why:慢查询日志里显示执行了 0.001 秒,但应用端记录的这条 SQL 耗时 3 秒?

  • 现象:MySQL 侧没有慢查询,但 Java 应用端 SQL 执行超时
  • 根因:慢查询日志只记录 SQL 在 MySQL 引擎内的执行时间,不包含:等待连接池分配连接的时间、网络传输时间、结果集在网络上传输的时间(如果返回了几万行数据)。连接池满了排队等 2.9 秒 + SQL 执行 0.001 秒 = 应用端记录 3 秒

7. 十万个why:UPDATE 语句 WHERE 条件明明命中了索引,为什么还是锁了整张表?

  • 现象:WHERE 条件用了索引列,EXPLAIN 也显示走了索引,但并发请求还是被阻塞
  • 根因:InnoDB 在 RR 隔离级别下,对于范围查询(BETWEEN、>、<)会加 Next-Key Lock(记录锁 + 间隙锁),锁定的范围远大于实际更新的行数。如果索引选择性差(比如 status 字段只有几个值),间隙锁可能覆盖了大量不相关的行,效果接近锁表

8. 十万个why:一条 COUNT(*) 查询,InnoDB 要 3 秒,MyISAM 只要 0.001 秒,为什么差这么多?

  • 现象:同样是 SELECT COUNT(*) FROM table,引擎不同耗时差几千倍
  • 根因:MyISAM 在表级别维护了一个行数计数器,COUNT(*) 不需要扫描数据直接返回。InnoDB 因为 MVCC 的存在,每个事务看到的行数可能不同(有些行对当前事务不可见),所以必须逐行扫描索引来统计。这也是为什么 InnoDB 的 COUNT 性能跟数据量成正比

Redis 方向

9. 十万个why:Redis 的 KEYS * 命令在开发环境用得好好的,为什么到了线上执行一次直接把服务打挂了?

  • 现象:在生产 Redis 上执行 KEYS 命令,所有业务请求瞬间超时
  • 根因:Redis 是单线程模型,KEYS 命令会遍历整个 keyspace,数据量大时需要几秒甚至几十秒。在这段时间内 Redis 无法处理任何其他命令,所有客户端请求全部阻塞。这就是为什么 Redis 官方文档用红色警告标注 KEYS 只能用于调试,生产环境用 SCAN 代替

10. 十万个why:Redis 设了 maxmemory 10GB 和 allkeys-lru 淘汰策略,为什么内存用到 6GB 就开始大量淘汰 Key?

  • 现象:内存明明没到上限,但 Key 被提前淘汰了
  • 根因:maxmemory 包含的不只是数据本身占的内存,还有 Redis 内部数据结构的开销(指针、哈希表、跳表节点)、输出缓冲区(client-output-buffer)、AOF 重写缓冲区。一个存了 100 字节 value 的 Key,实际内存占用可能是 200-300 字节。所以 10GB maxmemory 实际能存的业务数据可能只有 5-6GB

11. 十万个why:用 Redis 的 SETNX 做分布式锁,压测发现偶尔两个线程同时拿到了锁?

  • 现象:SETNX + EXPIRE 两条命令做锁,偶发两个客户端同时进入临界区
  • 根因:SETNX 和 EXPIRE 不是原子操作。线程 A 执行 SETNX 成功后、还没执行 EXPIRE 就挂了,Key 永不过期变成了死锁;或者线程 A 的 EXPIRE 还没执行就发生了网络抖动,线程 B 抢到了执行权。这就是为什么要用 SET key value NX EX seconds 这一条原子命令,或者直接用 Redisson

Kafka / 消息队列方向

12. 十万个why:Kafka 消费者组里加了一台新消费者,为什么加入的瞬间整个消费组都停了几十秒?

  • 现象:扩容时新实例加入,所有消费者短暂停止消费
  • 根因:新消费者加入触发 Rebalance,在 Eager 协议下所有消费者必须先释放所有 Partition(Stop the World),等协调器重新分配完毕才能继续消费。如果消费者数量多或 Partition 数量多,这个过程可能持续几十秒甚至几分钟。用 CooperativeStickyAssignor 可以做增量 Rebalance,只迁移需要变动的 Partition

13. 十万个why:消费者明明只 poll 了 500 条消息,为什么 Kafka Broker 的网络出口带宽瞬间被打满?

  • 现象:max.poll.records=500 但网络流量远超预期
  • 根因:max.poll.records 限制的是 poll() 一次返回给应用层的消息条数,不是 Broker 发送给消费者的数据量。Broker 按 fetch.max.bytes(默认 50MB)为上限打包数据发送。如果每条消息 100KB,500 条就是 50MB,一个消费者组 10 个消费者同时 fetch 就是 500MB。再加上消费者的 fetch.min.bytes 设了 1 就会频繁拉取

14. 十万个why:给 Kafka Topic 增加了 Partition 数量,为什么之前按 Key 路由的消息全乱了?

  • 现象:扩 Partition 之后,相同 Key 的消息被发到了不同的 Partition
  • 根因:Kafka 默认的分区策略是 murmur2(key) % numPartitions。Partition 数量变了,取模的结果就变了。之前 Key="order_123" 可能路由到 Partition 2,扩容后变成了 Partition 5。如果业务依赖相同 Key 在同一个 Partition 保证顺序消费,扩 Partition 就是个破坏性操作。要么提前规划好 Partition 数量,要么自定义 Partitioner 做一致性哈希

网络 / HTTP 方向

15. 十万个why:Nginx 反向代理后面的 Spring Boot 应用,为什么 request.getRemoteAddr() 拿到的永远是 127.0.0.1?

  • 现象:获取客户端 IP 永远是 Nginx 的地址而不是用户真实 IP
  • 根因:Nginx 代理之后,Spring Boot 看到的 TCP 连接来源就是 Nginx。真实客户端 IP 在 X-Forwarded-For 或 X-Real-IP 请求头里。但如果 Nginx 没配 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for,这些头就是空的。更危险的是如果不校验这个头的来源,攻击者可以伪造 X-Forwarded-For 绕过 IP 白名单

16. 十万个why:前端调后端接口,浏览器明明只发了一次请求,为什么后端收到了两次?

  • 现象:Network 面板里只有一个请求,但后端日志显示进了两次 Controller
  • 根因:跨域请求(CORS)会先发一个 OPTIONS 预检请求,后端如果没有正确处理 OPTIONS 会把它当成正常请求处理一次,然后真正的 POST/PUT 请求再处理一次。如果预检请求没有被缓存(Access-Control-Max-Age),每次跨域请求都会先走一遍 OPTIONS

17. 十万个why:HTTP 接口压测 QPS 到 500 就上不去了,CPU 和内存都没打满,瓶颈在哪?

  • 现象:服务器资源充足但 QPS 有天花板
  • 根因:最常见的原因是下游连接池设小了——HttpClient 的 maxConnPerRoute 默认只有 2(或 5),Tomcat 的 maxConnections 默认 8192 但 maxThreads 只有 200,数据库连接池默认 10 个。还有 Linux 的 TIME_WAIT 积压导致端口耗尽(短连接场景下)、TCP backlog 队列满了。瓶颈往往不在 CPU/内存,而在连接数和线程数

大模型 / AI Agent 方向

18. 十万个why:同一个 Prompt 发给大模型两次,为什么两次回答完全不一样?

  • 现象:Prompt 一字不差,回答结果却不同
  • 根因:大模型生成文本是基于概率采样的,temperature > 0 时每次采样的随机种子不同,选择的下一个 token 就可能不同。一个 token 的差异会像蝴蝶效应一样影响后续整段生成。设 temperature=0 可以让输出"几乎"确定性,但不同批次的推理(不同 GPU、不同 batch size)浮点运算的微小差异仍可能导致不同结果

19. 十万个why:RAG 检索用了 Top-5,但塞进 Prompt 的上下文越多,大模型回答的准确率反而越低?

  • 现象:召回 Top-1 时答对了,召回 Top-5 时反而答错了
  • 根因:这是"Lost in the Middle"现象——LLM 对长上下文中间部分的信息关注度明显低于开头和结尾。Top-5 里如果正确答案排在第 3 位,前后被不太相关的文档包围,模型可能忽略正确内容转而引用排名靠前或靠后的干扰文档。少而精的上下文比多而杂的上下文效果好

20. 十万个why:大模型明明答对了,为什么经过 JSON 结构化输出后答案就变了?

  • 现象:让模型自由回答是正确的,让它输出 JSON 格式后答案就错了
  • 根因:结构化输出(JSON mode / Function Calling)给模型加了格式约束,模型的注意力一部分被分配到"生成合法的 JSON 结构"上,留给"推理正确答案"的注意力就少了。特别是当 JSON Schema 复杂(嵌套深、字段多)时,模型在格式和内容之间的 trade-off 更明显。可以用"先推理再格式化"的两步策略来缓解

JVM / 运行时方向

21. 十万个why:Java 应用跑了几天后响应越来越慢,重启一下又好了,是什么在"漏"?

  • 现象:服务不重启就越来越慢,重启后恢复正常
  • 根因:经典的内存泄漏或资源泄漏。常见的:ThreadLocal 没 remove 导致线程池的线程持有越来越多的对象引用;数据库连接/HTTP 连接没正确关闭(try-with-resources 没用好);缓存没有过期策略导致 HashMap 越来越大;类加载器泄漏(热部署场景)。用 arthas 的 dashboard + heapdump 可以快速定位

22. 十万个why:JVM 明明分配了 4GB 堆内存,为什么进程的 RSS(实际物理内存)占了 6GB?

  • 现象:-Xmx4g 但 top 显示进程用了 6GB
  • 根因:JVM 的内存不只有堆。Metaspace(类元数据)、线程栈(每个线程默认 1MB × 200 个线程 = 200MB)、直接内存(NIO 的 DirectByteBuffer)、JIT 编译后的本地代码缓存、GC 数据结构本身也要占内存。堆内存只是 JVM 总内存的一部分。容器环境下如果 K8s 的 memory limit 只设了 4GB 而 -Xmx 也设了 4GB,大概率会被 OOMKilled

23. 十万个why:Full GC 只花了 200ms,为什么应用却停顿了 5 秒?

  • 现象:GC 日志显示 Full GC 耗时很短,但监控显示接口超时
  • 根因:GC 日志里的 200ms 是 STW(Stop-The-World)的时间,但在 STW 之前 JVM 需要让所有线程到达安全点(Safepoint)。如果有线程在执行一个大的 for 循环(JIT 编译后的 counted loop 不会插入安全点检查),其他线程就要等这个线程跑完循环才能进入 STW。等待安全点的时间不算在 GC 时间里,但用户体感的停顿是真实的
上次编辑于: