Java 业务开发常见陷阱:并发、池化、HTTP、事务与索引的工程化实践

Java 业务系统真正难处理的部分,往往不是把一个接口写通,而是在并发、超时、重试、资源耗尽和异常回滚同时发生时,仍然保证数据正确、吞吐稳定且问题可定位。本文围绕并发容器与锁、线程池、连接池、HTTP 调用、Spring 声明式事务和 MySQL 索引六条主线,重新组织这些看似分散的问题,并结合 JDK 21、Spring Framework 6.x、Spring Cloud OpenFeign、Apache HttpClient 5、Jedis 新版和 MySQL 8.x 的变化,形成一套更适合当前 Java 服务端开发的工程实践。

从“代码能跑”到“系统能扛”:Java 业务代码真正难在哪里

很多线上事故表面上完全不同:某个请求拿到了别人的用户信息、明明用了 ConcurrentHashMap 统计结果却不对、线程数突然涨到几千、数据库连接池耗尽、HTTP 调用偶发重复、加了 @Transactional 数据还是没回滚、给字段建了索引 SQL 反而不走索引。

这些问题背后其实有一条共同主线:业务代码必须理解自己所处的运行时边界。

一个普通的 Spring Boot 接口,从进入 Tomcat/Jetty 线程开始,到访问 Redis、数据库、下游 HTTP 服务,再到事务提交和 SQL 执行,中间至少跨过线程、锁、线程池、连接池、网络、事务和存储引擎几个边界。每一层都提供了“看起来已经帮你处理好了”的抽象,但抽象只能保证它承诺的那一部分,无法替业务代码保证整体正确性。

flowchart LR
    A[Web 请求] --> B[工作线程 / 虚拟线程]
    B --> C[共享内存与并发容器]
    B --> D[线程池 / 异步任务]
    B --> E[HTTP / Redis / DB Client]
    E --> F[连接池]
    F --> G[下游服务 / Redis / MySQL]
    G --> H[事务与索引]

    C -.错误边界.-> C1[复合操作非原子]
    D -.资源边界.-> D1[线程和队列耗尽]
    F -.资源边界.-> F1[连接等待与连接泄漏]
    E -.网络边界.-> E1[超时 重试 幂等]
    H -.一致性边界.-> H1[回滚 传播 执行计划]

因此,判断一段业务代码是否可靠,不应只问“API 是不是线程安全”“有没有加锁”“有没有事务”,而应该继续追问:

  • 这个对象是不是被多个线程共享?
  • 单个 API 原子,是否意味着多个 API 组合后仍然原子?
  • 队列、线程、连接这些资源有没有上限?
  • 超时发生以后,远端业务到底停没停?
  • 重试是不是可能把有副作用的操作执行两次?
  • 事务是否真的经过代理?异常是否真的触发回滚?
  • 索引存在,优化器为什么仍然可能选择全表扫描?

后面的所有问题,都可以从这几个问题逐步推出来。

并发工具:线程安全的容器,不等于线程安全的业务

Web 应用天然就是多线程环境

很多线程安全问题的根源,是开发者认为“我没有显式 new Thread(),所以我的代码不是多线程代码”。

事实上,只要代码运行在常见 Web 容器中,请求就已经由工作线程并发执行。传统 Servlet 模型下,Tomcat 会维护工作线程池,同一个线程处理完请求 A 后,可能继续处理请求 B。JDK 21 以后,即使应用开始采用虚拟线程,请求之间依然是并发执行,只是线程创建模型发生了变化。

这也是 ThreadLocal 最容易踩坑的地方。

ThreadLocal 解决的是“同一个变量在不同线程之间隔离”,不是“一个值自动随着一次 HTTP 请求结束而消失”。如果工作线程被线程池复用,而业务代码没有清理线程本地数据,那么下一次落到同一工作线程的请求就可能读到上一次请求遗留下来的状态。

典型的用户上下文代码应该把生命周期写完整:

1
2
3
4
5
6
7
8
9
10
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

public void handleRequest(UserContext userContext) {
try {
CONTEXT.set(userContext);
doBusiness();
} finally {
CONTEXT.remove();
}
}

这里真正重要的不是 remove() 这个 API 本身,而是一个通用原则:

只要上下文绑定在线程上,就必须明确它何时创建、何时清理,以及线程是否会被复用。

在线程池、Servlet 容器、异步执行器中都要遵守这个原则。请求上下文、租户 ID、链路追踪信息、认证信息等,都不应该依赖“线程结束后自然消失”这种假设。

还有一个容易被忽略的细节是 ThreadLocalRandom。正确使用方式仍然是:

1
int value = ThreadLocalRandom.current().nextInt(100);

不要为了少调用一次 current() 而把某次取得的 ThreadLocalRandom 实例缓存起来跨线程使用。JDK 21 官方 API 仍明确要求其方法由当前线程调用。

ConcurrentHashMap 只保证它承诺的原子操作

HashMap 改成 ConcurrentHashMap,并不能把任意业务逻辑自动变成原子操作。

假设一个 Map 当前有 900 个元素,希望多个线程把它补到 1000:

1
2
int gap = 1000 - map.size();
map.putAll(loadData(gap));

size() 本身可以安全执行,putAll() 内部也不会把容器结构写坏,但“读取当前大小 -> 计算差值 -> 补充数据”是三个步骤。多个线程完全可以在彼此之间穿插执行:

1
2
3
4
线程 A: size() -> 900,准备补 100
线程 B: size() -> 900,准备补 100
线程 C: size() -> 964,准备补 36
...

最终 Map 结构仍然是正确的,但业务状态已经超过 1000。

这就是并发编程里很重要的区分:

  • 数据结构线程安全:并发读写不会把容器自身搞坏。
  • 单个方法原子:某个 API 对指定 Key 的操作不可被拆开观察。
  • 业务操作原子:一整段“读状态 -> 判断 -> 修改”的业务规则必须作为一个整体成立。

ConcurrentHashMap 无法知道你的业务规则是什么,因此最后一层只能由业务代码决定。

如果一整段复合逻辑必须保持一致,最直接的方式仍然是对这段逻辑建立统一同步边界:

1
2
3
4
5
6
synchronized (map) {
int gap = targetSize - map.size();
if (gap > 0) {
map.putAll(loadData(gap));
}
}

但很多场景不必退回到“大锁”。ConcurrentHashMap 已经提供了 computecomputeIfAbsentcomputeIfPresentmergeputIfAbsent 等原子复合 API,应优先判断这些 API 能否直接表达业务意图。

用 computeIfAbsent + LongAdder 做高并发计数

高并发统计某个 Key 的出现次数,是理解并发容器正确用法的典型场景。

一个看似合理但并发度很差的实现是:

1
2
3
4
synchronized (counter) {
Long old = counter.get(key);
counter.put(key, old == null ? 1L : old + 1);
}

这个方案功能正确,但所有 Key 的计数都被同一把锁串行化了。

更适合高并发的写法是:

1
2
3
4
5
6
private final ConcurrentHashMap<String, LongAdder> counter =
new ConcurrentHashMap<>();

public void increment(String key) {
counter.computeIfAbsent(key, k -> new LongAdder()).increment();
}

这里有两层并发优化:

computeIfAbsent 保证针对这个 Key 的初始化作为原子操作执行;LongAdder 又通过分散热点竞争的方式处理高并发累加。JDK 21 的 LongAdder 文档甚至直接把这段模式作为可扩展频率统计的典型用法。

需要注意,computeIfAbsent 的 mapping function 应该保持短、简单、无副作用。它并不是让你把一段耗时远程调用、复杂事务逻辑全塞进去的容器。

CopyOnWriteArrayList 不是“更高级的 ArrayList”

CopyOnWriteArrayList 的设计非常明确:写操作通过复制底层数组产生新快照,读操作直接读取当前快照,因此读路径几乎不需要争抢同一把锁。

这使它特别适合:

  • 配置监听器列表;
  • 订阅者列表;
  • 极少更新、频繁遍历的规则集合;
  • 希望迭代过程中看到稳定快照的场景。

反过来,如果列表频繁 addremoveset,每次修改都要复制数组,性能和内存分配都会非常糟糕。

原始实验在大量写场景中观察到 CopyOnWriteArrayList 相比同步 ArrayList 出现接近两个数量级的差距,而大量读时又明显更快。具体倍数会随 JDK、硬件、数据规模变化,但方向不会变:Copy-On-Write 的成本被主动移动到了写路径。

因此,选择并发容器时真正要问的是“读写比例是什么、是否需要快照语义、热点在哪里”,而不是“哪个类名字里带 concurrent”。

锁:锁住正确的对象,并且只锁必须保护的代码

一行代码也不一定是一个原子动作

两个字段 ab 依次递增:

1
2
3
4
void add() {
a++;
b++;
}

另一个线程同时读取:

1
2
3
if (a < b) {
// ...
}

即使 ab 都是 volatile,也只能改善可见性,不能把 a++b++ 或“读取 a -> 读取 b -> 比较”变成一个不可分割的整体。

如果业务不变量是“任何线程观察时 a 与 b 都必须处于同一个一致状态”,那么写操作和读操作必须使用同一个同步边界

1
2
3
4
5
6
7
8
public synchronized void add() {
a++;
b++;
}

public synchronized boolean same() {
return a == b;
}

锁解决的不是“某个方法里有多线程”这个问题,而是“多个线程是否会并发访问同一组共享状态”。

锁对象的生命周期必须覆盖被保护资源

另一个高频错误是:锁住了实例,但共享数据是静态字段。

1
2
3
4
5
6
7
class Data {
private static int counter;

public synchronized void increase() {
counter++;
}
}

synchronized 实例方法锁住的是 this。如果每次调用都 new Data(),不同线程拿的是不同对象锁,而 counter 却属于整个类,实际没有形成互斥。

正确思路是让锁和资源处于同一作用域:

1
2
3
4
5
6
7
8
9
10
class Data {
private static int counter;
private static final Object COUNTER_LOCK = new Object();

public void increase() {
synchronized (COUNTER_LOCK) {
counter++;
}
}
}

也可以锁 Data.class,但专门的锁对象更容易把不同资源拆成不同锁,避免无关逻辑互相阻塞。

锁的粒度决定吞吐量

在 Spring Web 应用里,ControllerServiceRepository 通常是单例 Bean。如果随手给一个 Service 的整个业务方法加 synchronized,就等于让所有请求争抢同一个 Bean 的对象锁。

假设真正需要保护的只有一个共享 List,而前面有一个 10ms 的耗时操作:

1
2
3
4
5
6
public void wrong(Integer value) {
synchronized (this) {
slowOperation();
data.add(value);
}
}

这会把完全不需要互斥的 slowOperation() 也串行化。更合理的范围是:

1
2
3
4
5
6
public void right(Integer value) {
slowOperation();
synchronized (data) {
data.add(value);
}
}

原始演示中,同样 1000 次操作,粗粒度锁耗时约 11 秒,而只锁必要代码约 1.4 秒。这个数字不是通用基准,但非常直观地说明:锁的临界区越大,能够并行执行的代码越少。

如果普通互斥锁仍然是瓶颈,再进一步看业务访问模式:

  • 读远多于写:可以评估 ReentrantReadWriteLock
  • 读竞争较低、允许乐观读校验:可以评估 StampedLock
  • 高竞争计数:优先考虑 LongAdderAtomicLong 等原子类;
  • 没有明确公平性要求时,不要因为“公平听起来更正确”就默认使用公平锁,公平性通常会牺牲吞吐量。

多把锁最危险的不是慢,而是循环等待

一个订单需要同时锁定多个商品库存时,如果不同线程获取商品锁的顺序不同,就可能形成经典死锁:

1
2
线程 A:持有 item1,等待 item2
线程 B:持有 item2,等待 item1

最实用的解决方法不是“再加一个大锁”,而是建立全局一致的锁顺序

1
2
3
4
5
6
7
List<Item> items = cart.stream()
.sorted(Comparator.comparing(Item::getId))
.toList();

for (Item item : items) {
item.lock().lock();
}

所有线程都按同一个顺序获得锁,就破坏了死锁成立所需要的循环等待条件。

真实系统还应同时考虑:

  • 使用 tryLock(timeout) 避免无限等待;
  • lock()unlock() 必须严格配对,unlock() 放在 finally 中;
  • 锁住的对象集合如果由业务动态决定,排序的是“本次需要加锁的对象”,不是数据库中的全部对象;
  • 分布式锁还要考虑租约过期、业务仍未执行完、锁被其他实例重新获取的问题,因此锁续期和业务幂等必须一起设计。

本地锁保证的是进程内互斥,分布式锁也只是在更大范围内提供互斥入口。真正不能重复执行的业务,最终仍应通过业务状态机、唯一约束、幂等号等机制兜底。

线程池:线程有上限,队列也必须有上限

为什么 Executors 的快捷线程池容易变成生产事故

Executors.newFixedThreadPool()Executors.newCachedThreadPool() 使用方便,但隐藏了两个非常关键的事实。

固定线程池默认使用几乎无界的 LinkedBlockingQueue

1
2
3
4
5
6
7
8
线程数固定
任务处理不过来

任务不断堆积在队列

对象无法回收

Java Heap OOM

缓存线程池使用 SynchronousQueue,核心线程数是 0,最大线程数可以增长到非常大。任务一旦无法立即交给空闲线程,就会创建新线程:

1
2
3
4
5
6
7
8
9
下游接口突然从 100ms 变成 60s

请求持续进入

每个慢任务都占用一个线程

线程数迅速膨胀

unable to create new native thread

因此,生产系统使用平台线程池时,最好显式构造 ThreadPoolExecutor,把资源边界写出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static final AtomicInteger THREAD_ID = new AtomicInteger();

private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
8,
32,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
r -> {
Thread t = new Thread(r);
t.setName("order-io-" + THREAD_ID.incrementAndGet());
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy()
);

这里的 8/32/1000 不是推荐默认值,只是展示应该显式回答三个问题:最多保留多少核心工作线程、流量突发时允许扩到多少线程、处理不过来的任务最多排队多少个。

ThreadPoolExecutor 的默认调度顺序

理解线程池最关键的是记住它不是“先扩线程再排队”,默认顺序恰恰相反:

flowchart TD
    A[提交任务] --> B{当前线程数 < corePoolSize?}
    B -- 是 --> C[创建核心工作线程]
    B -- 否 --> D{任务能进入工作队列?}
    D -- 是 --> E[进入队列等待]
    D -- 否 --> F{当前线程数 < maximumPoolSize?}
    F -- 是 --> G[创建非核心工作线程]
    F -- 否 --> H[执行拒绝策略]

所以,如果队列设置得非常大,maximumPoolSize 很可能长期不起作用,因为任务会优先在队列里越堆越多。

这也是线程池参数必须组合考虑的原因:

参数 主要控制什么 常见错误
corePoolSize 稳态并发能力 只按 CPU 核数机械设置
maximumPoolSize 突发扩容上限 队列太大导致永远扩不到这里
workQueue 排队和削峰能力 无界队列最终把问题转成 OOM
keepAliveTime 非核心线程回收速度 过短导致频繁创建销毁
ThreadFactory 线程命名、属性 所有线程都是 pool-1-thread-x,事故难定位
RejectedExecutionHandler 系统过载时怎么退化 不理解语义就选择 CallerRunsPolicy

prestartAllCoreThreads() 可以提前启动核心线程;allowCoreThreadTimeOut(true) 可以允许核心线程在空闲后回收。是否需要使用,要根据流量形态而不是固定模板决定。

CallerRunsPolicy 不是“不会丢任务”这么简单

CallerRunsPolicy 的特殊之处是,线程池和队列都满时,会让提交任务的线程自己执行任务

如果提交者是后台调度线程,这相当于自然产生了反压;如果提交者是 Tomcat 请求线程,则原本想异步执行的慢任务突然变成同步执行,请求线程会被拖住。

因此:

拒绝策略不是线程池的尾部参数,而是系统过载策略的一部分。

选择它时必须明确“谁在提交任务、提交线程被阻塞是否可接受、任务允许丢弃还是必须落盘重试”。

线程池必须复用,但不同任务不应该无脑共用一个池

池化的意义是复用。如果一个工具方法每次调用都 newCachedThreadPool(),虽然名字叫“线程池”,实际上每个请求都在创建一批新的线程资源,最终仍然可能有成百上千个线程池存活。

反过来,“线程池应该复用”也不等于“整个应用只能有一个公共线程池”。

文件批处理、第三方 HTTP 调用、低延迟计算、发送消息等任务的资源特征完全不同。如果把它们塞进同一个小线程池,某个慢 IO 任务可以把整个池占满,连本来很快的内存计算都排不上队。

比较合理的隔离方式是按任务资源特征和故障域划分:

  • CPU 密集:线程数通常接近可用 CPU 核数,避免过度上下文切换;
  • 阻塞 IO:平台线程模式下通常需要更多并发线程,但必须结合下游最大连接数和 SLA;
  • 长耗时批任务:避免和在线请求共享线程池;
  • 调用不同关键下游:必要时拆池,避免一个下游雪崩拖死所有异步任务;
  • parallelStream() 默认使用公共 ForkJoinPool,包含阻塞 IO 时尤其要谨慎,避免污染公共池。

JDK 21 虚拟线程改变了“线程数量”,但没取消“资源上限”

JDK 21 的虚拟线程适合大量阻塞型任务。Executors.newVirtualThreadPerTaskExecutor() 会为每个任务创建一个虚拟线程,不再需要像平台线程一样维护固定大小的线程池。

1
2
3
4
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Result> future = executor.submit(this::callRemoteService);
Result result = future.get();
}

但官方文档同样强调:虚拟线程用于提高吞吐规模,不是让单个任务跑得更快。更重要的是,虚拟线程便宜,不代表数据库连接、HTTP 连接、第三方接口 QPS、文件句柄也变便宜。

如果某个下游最多只允许 100 个并发请求,使用虚拟线程后仍然应该显式限流:

1
2
3
4
5
6
7
8
9
10
private final Semaphore remoteLimit = new Semaphore(100);

public Result callWithLimit() throws InterruptedException {
remoteLimit.acquire();
try {
return remoteClient.call();
} finally {
remoteLimit.release();
}
}

所以 JDK 21 时代新的经验不是“线程池不用学了”,而是:

平台线程时代,线程本身就是稀缺资源;虚拟线程时代,线程不再是主要瓶颈,但后端连接、队列容量、外部服务并发和业务反压仍然必须显式管理

线程池必须被监控,而不是只在拒绝时才发现有问题

线程池经常在真正失败前保持“沉默”。队列从 0 堆到 900,并不会自动抛异常;活跃线程长期打满,也不会主动告诉业务这是故障前兆。

至少应该持续观察:

1
2
3
4
5
pool.getPoolSize();
pool.getActiveCount();
pool.getQueue().size();
pool.getCompletedTaskCount();
pool.getTaskCount();

生产系统应该把这些指标接入 Micrometer/Prometheus,而不是依赖临时日志。比较有价值的报警通常包括:

  • 活跃线程长期接近最大线程数;
  • 队列使用率持续上升;
  • 拒绝次数出现非零;
  • 单任务执行时间或队列等待时间明显增加;
  • 线程数在短时间异常膨胀。

连接池:真正需要复用的是“池”,不是某一条连接

先看清 SDK 到底是哪一种连接模型

使用任何网络客户端之前,都应该先回答一个问题:这个 Client 是线程安全的池化客户端,还是一条具体连接?

常见 API 大致分三类:

模型 常见形态 正确使用方式
池与连接分离 XXXPool -> XXXConnection Pool 长期复用;每次借连接,使用后归还
Client 内置连接池 XXXClient Client 通常长期复用,由内部借还连接
单连接 Client XXXConnection / 某些简单 Client 通常不跨线程共享,必要时外层自己池化

不能只靠类名判断。最可靠的方式依次是:官方文档、线程安全说明、源码一路看到 Socket/Channel 与 Client 的关系。

Jedis 的经典问题:一个 Jedis 就是一条连接

早期 Jedis 的经典实现中,一个 Jedis 对象最终对应一个 Socket 以及它的输入输出流。如果两个线程共享同一个 Jedis,同时发送 GET aGET b,协议字节和响应读取都可能互相穿插,最终出现响应错位、连接异常等非常难解释的问题。

历史上推荐的方式是复用 JedisPool,每次借出独立 Jedis:

1
2
3
try (Jedis jedis = jedisPool.getResource()) {
return jedis.get("order:1001");
}

try-with-resources 结束时的 close() 对池化 Jedis 来说会归还资源,而不是简单粗暴地销毁整个连接池。

这一原则今天仍然成立:不要在多个线程之间共享一个单连接 Jedis。

不过版本上已经发生变化。Jedis 7.x 的当前主干文档已经把传统 JedisPool 标记为兼容旧代码的 pooled client,并推荐生产代码优先使用新的 RedisClient。因此,阅读旧案例时应保留“单连接不可跨线程乱共享、池化 Client 要复用”这两个原理,而不要机械照搬旧版构造 API。

HttpClient 本身就是重量级、可复用的连接池客户端

Apache HttpClient 的 CloseableHttpClient 通常会配合 PoolingHttpClientConnectionManager 使用。如果每个业务请求都创建一个新的 HttpClient,相当于每次都重新创建连接池和连接管理器,TCP 长连接根本复用不起来。

池化调用正确的结构应该是:

flowchart LR
    A[业务线程] --> B[共享 HttpClient]
    B --> C[Pooling Connection Manager]
    C --> D1[TCP Connection 1]
    C --> D2[TCP Connection 2]
    C --> D3[TCP Connection N]
    D1 --> E[Remote Service]
    D2 --> E
    D3 --> E

原始本地测试中,每次创建连接池的 QPS 约 337,而复用连接池约 2022。这个结果高度依赖测试环境,不应该拿来作为容量目标,但它说明了一个事实:反复 TCP 建连和销毁的成本远高于从已有池中租用持久连接。

当前 Apache HttpClient 5 仍然使用 per-route 和 total 两级连接限制。Spring Cloud OpenFeign 4 以后也不再支持 Apache HttpClient 4,官方建议使用 HC5。

连接池最重要的不是“最大值”,而是供需关系

数据库、Redis、HTTP 连接池都绕不开最大连接数。设置过小,请求会长时间等待连接;设置过大,客户端和服务端都会承担额外连接、线程、内存和上下文切换开销。

以数据库事务为例,一次事务从拿到 Connection 到 commit/rollback 之前,通常会持续占用连接。如果一个事务中人为等待 500ms,那么在 50 QPS 下,仅这个操作的平均并发占用就可能接近:

1
2
3
并发连接 ≈ QPS × 平均持有连接时间
≈ 50 × 0.5
≈ 25

如果池只有 10 个连接,其余请求必然排队。

这不是说应该立刻把连接池改成 1000,而是应该观察实际指标:

  • active connections;
  • idle connections;
  • pending / threads awaiting connection;
  • connection acquire latency;
  • connection timeout count;
  • 数据库本身能够承受的并发连接和 SQL 压力。

HikariCP 等连接池都能暴露运行指标。配置改完后必须通过监控验证是否真正生效,不要只相信配置文件里的值。

历史上一个很典型的事故就是:应用从 Druid 切换到了 HikariCP,但运维仍修改旧的 Druid maxActive,文件看起来已经“调大”,实际运行参数完全没变化。

分清“等待池中连接”和“建立 TCP 连接”两个超时

连接池场景通常至少存在两种不同超时:

1
2
3
4
5
6
7
8
9
10
11
业务线程

│ 等连接池资源
│ connectionRequestTimeout / connectionTimeout(pool)

连接池

│ 建立 TCP 连接
│ connectTimeout

服务端

例如 HikariCP 的 connectionTimeout 是“从池里等待数据库连接”的最长时间,而 JDBC URL 中的 connectTimeout 通常才是数据库驱动建立 TCP 连接的超时。

Apache HttpClient 也区分:

  • connection request timeout:从连接池租连接等待多久;
  • connect timeout:TCP 建连阶段等待多久;
  • response/socket timeout:连接已经建立后,等待对端响应数据多久。

如果把这些参数混在一起,排查“连接超时”时很容易找错方向。

HTTP 调用:超时、重试和并发必须放在一起设计

Connect Timeout 和 Read Timeout 是两个完全不同的问题

HTTP 最常见的两个超时可以按调用阶段理解:

sequenceDiagram
    participant C as Client
    participant P as Proxy / LB
    participant S as Server

    C->>P: TCP connect
    Note over C,P: Connect Timeout
    P->>S: 转发 / 建连
    C->>S: HTTP Request
    Note over C,S: 服务端执行业务
    S-->>C: HTTP Response
    Note over C,S: Read / Response Timeout

ConnectTimeout 限制的是建立连接阶段。内网服务正常情况下 TCP 建连通常很快,如果几秒还连不上,继续等几十秒大多没有意义。更重要的是,排查时要先搞清楚客户端到底连的是业务服务、Nginx、网关还是其他代理。

ReadTimeout/ResponseTimeout 限制的是已经发出请求后,等待响应数据的时间。它通常包含了服务端主要业务处理时间,因此绝不能把它简单理解成“网络传输 100ms 应该够了”。

客户端读取超时,不等于服务端任务已经终止

一个非常危险的误解是:客户端 2 秒超时抛出异常,于是认为远端业务也失败了。

实际情况通常是:

1
2
3
4
0s   客户端发送请求
0s 服务端线程开始执行业务
2s 客户端 ReadTimeout,主动放弃等待
5s 服务端仍然执行完成,并可能已经写入数据库

Tomcat 已经把请求交给业务线程后,客户端断开连接通常不会自动中断已经进入业务层的代码。

这会直接带来两个工程问题:

  • 客户端不能仅凭读取超时判断“远端一定失败”;
  • 如果客户端随后自动重试,第一次调用可能已经成功,于是副作用会执行两次。

支付、发券、扣库存、发送短信、创建订单等操作因此必须有幂等设计。

超时配置不是越短越好,也不是越长越稳

连接超时一般可以相对短,因为建连成功与否很快就应该有结果;读取超时则必须结合下游 SLA、接口类型和调用链预算。

读取超时太短:正常慢请求被误判失败,错误率上升。

读取超时太长:下游一旦变慢,上游请求线程、连接池和并发槽位会长期占用,最终形成级联故障。

在线同步接口尤其要从整个调用链看超时预算,例如:

1
2
3
4
5
API 总 SLA 800ms
├─ 本地逻辑 100ms
├─ DB 150ms
├─ 下游 A 200ms
└─ 下游 B 最多只能再给 250~300ms

如果每个下游都各自默认 30 秒,整个链路就没有真正的失败边界。

重试的前提不是“网络不稳定”,而是“操作可以重复”

旧版本 Spring Cloud Netflix Ribbon 会对部分 GET 请求在节点失败后进行自动重试,这曾经造成非常典型的“客户端只调用一次,短信却发送两次”问题。

问题本质不在 Ribbon,而在两个设计缺陷叠加:

  1. 一个有副作用的“发送短信”接口被设计成 GET;
  2. 调用方没有意识到底层客户端/负载均衡器存在自动重试。

HTTP 方法应该表达接口语义:

  • GET:读取资源,应该是 safe,并且通常具有幂等性;
  • PUT/DELETE:按 HTTP 语义应具备幂等性;
  • POST:通常表示创建或执行动作,本身不天然幂等,需要业务层幂等键保证。

当前 Spring Cloud 已经不再使用 Ribbon;Spring Cloud OpenFeign 默认还会注册 Retryer.NEVER_RETRY 来关闭 Feign 层自动重试,Spring Cloud LoadBalancer 则有独立的 retry 配置。因此旧项目中的 ribbon.MaxAutoRetriesNextServer 等参数已经属于历史配置,不能继续照抄到新项目。

当前 OpenFeign 的超时配置更直接:

1
2
3
4
5
6
7
8
spring:
cloud:
openfeign:
client:
config:
default:
connectTimeout: 2000
readTimeout: 5000

如果显式启用重试,无论是在 OpenFeign、Spring Cloud LoadBalancer、Resilience4j、网关还是 Nginx,都必须检查整条链路有没有多层重试叠加。两层各重试 3 次,在最坏情况下可能放大成远超预期的请求数。

HTTP 并发还会被连接池上限限制

线程池开 100 个线程,不代表能对同一个下游同时发出 100 个 HTTP 请求。

Apache HttpClient 的连接池会同时限制:

  • 单个 route/host 的最大连接数;
  • 整个连接池最大连接数。

如果每个 route 只允许 2 个连接,即使外面有 100 个线程,其余请求也只能等待连接。旧版 HttpClient 的默认 per-route 数很小,在爬虫、网关、代理、大并发下游调用场景中特别容易成为隐藏瓶颈。

因此,HTTP 性能问题要同时观察:

1
2
3
4
5
6
7
8
9
业务线程数

线程池队列

HTTP 连接池等待

per-route limit / total limit

下游真实处理能力

只增加线程数,经常只是把等待从线程池队列移动到连接池队列。

Spring 声明式事务:加了 @Transactional,只是“声明了意图”

事务能否生效,第一步看调用有没有经过代理

Spring 声明式事务默认建立在 AOP 代理上。可以把调用过程理解成:

flowchart LR
    A[Controller / Other Bean] --> B[Spring Proxy]
    B --> C[开启/加入事务]
    C --> D[Target Service Method]
    D --> E{正常返回?}
    E -- 是 --> F[commit]
    E -- 否 --> G[根据 rollback rule 判断]
    G --> H[rollback 或 commit]

如果同一个对象内部使用 this.xxx() 调用另一个 @Transactional 方法,调用根本没有经过 Spring Proxy:

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class UserService {

public void register() {
this.saveUser(); // self-invocation,默认代理模式下绕过事务拦截器
}

@Transactional
public void saveUser() {
// ...
}
}

因此,最稳妥的改造方式是把事务边界拆到另一个 Spring Bean,让外部调用自然经过代理,而不是到处注入 self 或使用 AopContext.currentProxy()

“必须是 public 方法”是有版本背景的

早期 Spring 的常见经验是:@Transactional 只应该放在 public 方法上。这对旧版本和 JDK 动态代理尤其重要,也是历史资料里的正确结论。

但 Spring Framework 6.0 之后需要更精确地理解:

  • JDK 接口代理:事务方法仍必须是接口中的 public 方法;
  • class-based proxy:protected 和 package-visible 方法也可以被事务代理;
  • private 方法依然不能被 class proxy 增强;
  • 不论哪种代理,默认 proxy mode 下 self-invocation 仍然绕过事务拦截

所以在当前 Spring 6.x 项目里,真正稳定、最容易读懂的工程规范仍然是:把事务入口设计成清晰的 Service 公共方法,并从 Bean 外部调用。 这样不用让代码行为依赖代理类型。

事务生效了,也不代表异常一定回滚

Spring 默认回滚规则仍然是:

  • RuntimeException:回滚;
  • Error:回滚;
  • checked Exception:默认不回滚。

因此下面代码即使事务成功开启,IOException 也可能导致事务提交:

1
2
3
4
5
@Transactional
public void importUser() throws IOException {
userRepository.save(user);
Files.readString(Path.of("not-exist.txt"));
}

如果业务语义要求任何异常都回滚,可以明确声明:

1
2
3
4
5
@Transactional(rollbackFor = Exception.class)
public void importUser() throws IOException {
userRepository.save(user);
Files.readString(Path.of("not-exist.txt"));
}

Spring Framework 6.2 又增加了全局调整默认 rollback 行为的能力,但项目是否要全局改成“所有异常都回滚”,仍然应该结合现有异常体系评估,不能在大系统里毫无回归测试地直接切换。

catch 住异常以后,事务拦截器可能根本不知道失败了

下面的代码是更常见的坑:

1
2
3
4
5
6
7
8
9
@Transactional
public void createUser() {
try {
userRepository.save(user);
throw new RuntimeException("failed");
} catch (Exception e) {
log.error("create user failed", e);
}
}

异常已经在事务方法内部被吞掉,代理看到的是“方法正常返回”,默认自然会提交。

更推荐的方式是让异常继续向外传播,让声明式 rollback rule 处理:

1
2
3
4
5
@Transactional(rollbackFor = Exception.class)
public void createUser() throws Exception {
userRepository.save(user);
doOtherWork();
}

如果业务确实必须在事务方法内部捕获异常,又要求当前事务回滚,可以显式标记:

1
2
3
catch (Exception e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}

但这会让业务代码直接依赖 Spring 事务 API,通常应当作为少数特殊场景使用,而不是默认写法。

REQUIRED 的子事务失败,即使 catch 了也可能出现 UnexpectedRollbackException

假设主流程创建主用户,子流程创建附属用户。两个方法默认都是 PROPAGATION_REQUIRED

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
主方法事务 T1
├─ save main user
└─ 调用子方法
├─ 继续使用 T1
└─ RuntimeException -> T1 被标记 rollback-only

主方法 catch 子异常

主方法“正常返回”

尝试 commit T1

发现 T1 已 rollback-only

UnexpectedRollbackException

这就是为什么“我已经 catch 住子方法异常了,为什么主事务还是回滚”并不矛盾。子方法没有独立事务,它把同一个物理事务标记成了 rollback-only。

如果业务明确要求“子操作失败自己回滚,但不能影响主操作”,应该使用独立事务:

1
2
3
4
5
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createSubUser(User user) {
repository.save(user);
// ...
}

外层方法再捕获这个独立事务的异常:

1
2
3
4
5
6
7
8
9
@Transactional
public void createMainUser(User user) {
repository.save(user);
try {
subUserService.createSubUser(user);
} catch (RuntimeException e) {
log.warn("sub user failed", e);
}
}

REQUIRES_NEW 会挂起外层事务,开启新的物理事务,所以两者可以独立 commit/rollback。

但这也意味着新的连接占用、锁释放时机和数据可见性变化。子事务如果需要读取外层尚未提交的数据,不能简单假设“反正都在一个调用链里就应该可见”,更合理的做法往往是显式把必要数据作为方法参数传入,重新检查事务边界设计。

本地事务无法自动覆盖外部 HTTP 副作用

如果一个方法里同时:

  1. 写本地数据库;
  2. 调第三方支付;
  3. 本地后续逻辑抛异常;

@Transactional 只能回滚它管理的数据库资源,不能把已经成功的远端支付一起“回滚”。

这类流程更合理的设计通常是:先落一个可恢复的本地业务单据,再调用外部服务;只有拿到明确成功/失败才推进最终状态;遇到超时或不确定结果,通过查询接口、消息或定时补偿恢复一致性。

这也是为什么事务设计最终一定会和前面的 HTTP 超时、重试、幂等问题汇合到一起。

MySQL 索引:索引是成本模型的一部分,不是 SQL 的强制开关

InnoDB 为什么以页为单位组织数据

InnoDB 数据最终存储在磁盘中,但数据库不可能每查询一行就随机读取一次磁盘,因此数据以页为基本管理单位。默认页大小通常是 16KB。

一页中包含多条记录、页目录和记录之间的链式关系;多个数据页继续通过更高层的索引节点组织起来,最终形成 B+ Tree。

B+ Tree 适合作为数据库索引,关键原因是:

  • 非叶子节点只保存索引导航信息,可以容纳很多 Key;
  • 树高通常很低,从根到叶只需要少量页访问;
  • 叶子节点按 Key 有序,特别适合范围扫描;
  • 顺序结构比普通二叉搜索树更适合磁盘页和缓存局部性。

聚簇索引、二级索引和回表

InnoDB 的聚簇索引叶子节点保存完整行数据,一张表的物理数据只能按一种主要顺序组织,因此聚簇索引只有一个。

通常主键就是聚簇索引键。如果没有主键,InnoDB 会寻找合适的唯一非空索引;再没有则内部生成隐藏聚簇索引。

二级索引的叶子节点不保存完整行,而是保存二级索引列和主键。因此:

flowchart LR
    A[WHERE name = 'mario'] --> B[二级索引 name]
    B --> C[得到主键 id=42]
    C --> D[聚簇索引 PRIMARY]
    D --> E[完整行数据]

第二次从主键聚簇索引取完整行就是回表

如果查询所需字段已经全部包含在二级索引中,就不需要回表:

1
2
3
4
5
CREATE INDEX idx_name_score ON person(name, score);

SELECT name, score
FROM person
WHERE name = 'mario';

这就是覆盖索引。它不仅减少随机访问,还意味着优化器可以直接在更小的二级索引结构上完成查询。

二级索引不是免费午餐

每增加一个二级索引,就多维护一棵 B+ Tree,至少有三类成本。

写入维护成本:插入、删除、更新索引列时,除聚簇索引外还要修改所有相关二级索引。页空间不足还可能发生页分裂,删除较多后可能出现页合并。

空间成本:二级索引要保存索引列以及主键,宽字符串、多列联合索引都会显著增加磁盘和 Buffer Pool 压力。

查询回表成本:二级索引命中大量记录时,每条记录都可能再次访问聚簇索引。如果选择性很低,回表次数太多,反而可能比直接扫描聚簇索引更贵。

因此,不应该看到一个字段出现在 WHERE 中就给它单独建索引。索引设计必须跟真实查询模式一起做。

常见“有索引却用不上”的情况

查询写法 为什么容易失效/变差 典型处理
LIKE '%abc' B+ Tree 无法从已知前缀定位 改前缀匹配,或使用专门搜索方案
WHERE LENGTH(name)=7 索引保存的是原始列值,不是运行时函数结果 生成列/函数索引(按版本能力)或重构条件
联合索引 (a,b,c) 只查 b 不满足最左前缀 调整索引顺序或增加合适索引
SELECT * 即使先走二级索引,也可能产生大量回表 只查必要列,利用覆盖索引
低选择性条件返回大量行 回表成本可能高于全表扫描 接受全表扫描或重新设计查询/索引

对于联合索引 (name, score)WHERE name=? 可以利用最左前缀,但只写 WHERE score=? 通常无法通过该 B+ Tree 的有序前缀直接定位。

WHERE 条件在 SQL 文本中的书写先后顺序通常不是这里的关键,因为优化器会做条件重排;真正关键的是索引自身的列顺序和查询条件能否形成有效的搜索范围。

MySQL 会基于成本主动放弃索引

即使 SQL 理论上可以走多个索引,MySQL 也会比较不同执行计划的估算成本。

成本大体来自:

  • 需要读取多少数据页;
  • 需要检查多少行;
  • 是否要回表;
  • 是否需要排序、临时表等额外操作;
  • 表和索引统计信息对基数、选择性的估计。

因此,一个范围条件在返回行较多时,优化器完全可能认为:

1
2
3
走二级索引 + 回表 25000 次
成本 >
直接顺序扫描聚簇索引

这不是“索引失效”,而是优化器认为不用索引更便宜

判断 SQL 性能时,至少要看:

1
EXPLAIN SELECT ...;

如果怀疑优化器的估算过程,可以使用 optimizer_trace

1
2
3
4
5
6
7
8
9
10
11
SET optimizer_trace = 'enabled=on';

SELECT *
FROM person
WHERE name > 'name84059'
AND create_time > '2026-09-01 05:00:00';

SELECT *
FROM information_schema.OPTIMIZER_TRACE\G

SET optimizer_trace = 'enabled=off';

在 MySQL 8.x,更实用的补充工具是:

1
2
EXPLAIN ANALYZE
SELECT ...;

EXPLAIN 主要告诉你优化器“计划怎么做”,EXPLAIN ANALYZE 会真正执行语句并展示估算行数与实际行数、实际耗时、循环次数等信息。它非常适合发现统计信息估算和真实执行之间的偏差。

如果明明应该走索引却选择异常,可以先考虑 ANALYZE TABLE 更新统计信息,而不是第一反应就长期使用 FORCE INDEX。强制索引更像对优化器的人工覆盖,数据分布变化后可能反过来成为负担。

把这些问题串起来:一次线上故障应该怎么定位

前面的内容看起来横跨 Java 并发、网络和数据库,但线上排查时它们往往是连续出现的。

例如某接口 RT 突然从 200ms 涨到 5s,可以按资源链逐层判断:

flowchart TD
    A[接口变慢] --> B{请求线程是否耗尽?}
    B -- 是 --> B1[看线程池/虚拟线程阻塞点]
    B -- 否 --> C{自定义线程池是否满载?}
    C -- 是 --> C1[active queue reject caller-runs]
    C -- 否 --> D{HTTP/Redis/DB 连接池是否等待?}
    D -- 是 --> D1[active idle pending acquire timeout]
    D -- 否 --> E{下游调用是否超时/重试?}
    E -- 是 --> E1[connect/read timeout retry amplification]
    E -- 否 --> F{数据库 SQL 是否变慢?}
    F -- 是 --> F1[EXPLAIN ANALYZE 锁等待 索引选择]
    F -- 否 --> G[检查 JVM GC CPU 锁竞争等]

不同症状也可以快速映射到高概率原因:

现象 优先检查
请求偶发读到别人的上下文 ThreadLocal 清理、线程复用、单例字段共享
计数/集合最终结果不符合业务规则 多 API 复合逻辑是否原子、锁范围是否正确
CPU 不高但接口极慢 线程池排队、连接池等待、下游阻塞
unable to create new native thread 无界平台线程、线程池未复用、慢任务堆积
Java Heap OOM 且任务大量排队 无界任务队列、队列中的大对象
异步任务突然拖慢 Web 请求 CallerRunsPolicy、公共线程池被打满
Hikari Connection is not available 连接持有时间、最大连接、等待线程、慢事务
HTTP 客户端报 read timeout 下游处理时间、网络、客户端读取预算
客户端超时但业务执行了两次 自动重试 + 非幂等接口
@Transactional 没回滚 self-invocation、异常被 catch、checked exception、事务管理器
UnexpectedRollbackException 内层 REQUIRED 已把共享事务标记 rollback-only
明明建了索引却全表扫描 最左前缀、函数、返回行数、成本模型、统计信息

这种排查方式的关键,是沿着请求真正经过的资源链看状态,而不是看到一个异常关键字就只改一个参数。

一套更适合当前 Java 服务端的工程检查清单

并发与锁

共享对象进入多线程环境之前,明确它是线程私有、请求私有、Bean 字段还是进程级共享对象。ThreadLocal 必须在完整生命周期中清理;并发容器只依赖其公开的原子语义,不把多个 API 组合后想当然视作原子;能够用 computemergeLongAdder 等表达的热点操作,不先上全局锁。

出现锁时,优先问锁保护的业务不变量是什么,再决定锁对象和范围。静态资源用类级/共享级锁,实例资源用对应实例锁;临界区只包含真正共享状态。多锁场景统一顺序,并配合超时、finally 释放和压测验证。

线程与任务执行

平台线程池显式声明线程数、队列、命名和拒绝策略。线程池要复用,但按任务性质隔离,避免一个慢任务池污染所有业务。监控 active、queue、reject 和执行耗时。

JDK 21 虚拟线程可以替代大量“为了阻塞 IO 而维护的超大平台线程池”,但不要因此移除 Semaphore、HTTP 连接上限、数据库连接池上限或业务限流。

网络与连接池

任何 SDK 先确认 Client 是否线程安全、是否内置连接池、Connection 是否可以共享。Client/Pool 作为长生命周期资源复用,程序关闭时优雅释放。

连接池参数至少监控 total、active、idle、pending、acquire latency;区分“等待池资源的超时”和“TCP 建连超时”。

HTTP 调用同时设计 connect timeout、response timeout、连接池容量、重试和幂等。任何一层开启重试,都要统计整条链路最坏请求放大倍数。

事务与数据库

事务边界放在真正的业务 Service 入口,调用明确经过 Spring Proxy。异常体系和 rollback 规则一起设计,不靠开发者记忆“这个 checked exception 会不会回滚”。事务传播按业务独立性决定,REQUIRES_NEW 不是为了“修异常”,而是明确表示独立物理事务。

索引设计从查询模式出发,用 EXPLAIN/EXPLAIN ANALYZE 证明效果;索引优化同时考虑写入成本、空间、覆盖索引和回表。优化器选择和预期不一致时先看统计信息与成本,再考虑 hint。

从 2020 到 2026:哪些结论应该保留,哪些 API 已经变化

这组问题的底层原则非常稳定,但具体框架已经发生明显演进。把旧案例用于今天的工程实践时,需要做版本转换。

JDK 并发: ThreadLocal 清理、ConcurrentHashMap 原子边界、CopyOnWriteArrayList 的读多写少适用性仍然成立。JDK 21 新增正式虚拟线程能力,使阻塞 IO 的并发模型发生变化,但虚拟线程并没有解决数据库连接数、HTTP 下游容量和业务反压问题。

Spring 事务: self-invocation 绕过 proxy 的原则仍然成立;Spring 6.0 以后 class-based proxy 可以支持 protected/package-visible 的事务方法,因此“只有 public 才能事务化”不再是绝对规则,但 private 和外部代理调用边界仍然要严格理解。Spring 6.2 还允许全局调整默认 rollback 规则。

Spring Cloud: Netflix Ribbon 已进入历史阶段,当前 OpenFeign 结合 Spring Cloud LoadBalancer 使用。旧项目中的 Ribbon 超时、自动重试参数只能作为理解历史行为的材料,新的项目应使用 spring.cloud.openfeign.*、Spring Cloud LoadBalancer 以及实际启用的 Circuit Breaker/Retry 组件配置。

HTTP Client: Apache HttpClient 5 是当前主线,OpenFeign 4+ 已不再支持 HttpClient 4。连接池复用、per-route/total 容量、连接请求超时和响应超时这些原理仍然完全适用。

Redis Jedis: 单个 Jedis 连接不应被多个线程并发共享的原则没变;传统 JedisPool 在新版本中正在被更统一的 RedisClient API 取代,老项目可以继续理解和维护 pool 模型,新项目应优先参考当前 Jedis 官方推荐。

MySQL: InnoDB 聚簇索引、二级索引、回表、覆盖索引和成本优化器这些基础没有变化;MySQL 8.x 增加并强化了 EXPLAIN ANALYZE 等能力,因此现在排查 SQL 不应该只停留在传统 EXPLAIN 的静态估算上。

总结

并发工具、锁、线程池、连接池、HTTP、事务和索引表面上属于完全不同的知识点,但真正进入业务系统后,它们共同决定了一件事:当请求并发、资源不足、网络失败和异常发生时,系统还能不能维持正确的状态。

ConcurrentHashMap 不会替你保证一整段业务逻辑原子,锁也不会因为“加上了”就自动锁对对象;线程池和连接池的价值来自复用和可控的资源边界,而不是简单套一个 Pool;HTTP 超时以后远端仍可能成功,所以重试必须和幂等一起设计;@Transactional 只是声明,真正的事务行为由代理边界、异常规则和传播方式决定;索引也只是优化器可选择的一种访问路径,不是创建以后就必然生效。

如果把这些问题统一成一条工程原则,可以概括为:不要只看 API 名字给出的安全感,要看它到底保证了哪一层语义,并为没有被保证的那一层建立清晰的边界、监控和失败策略。 这也是 Java 业务代码从“功能正确”走向“生产可靠”的真正分界线。


Java 业务开发常见陷阱:并发、池化、HTTP、事务与索引的工程化实践
https://allendericdalexander.github.io/2026/09/08/geeker/088java100error/1java-business-common-pitfalls/
作者
AtLuoFu
发布于
2026年9月8日
许可协议