并发09:并发设计模式
从生产者-消费者到 Worker Thread,再到 Future 模式,系统理解 Java 并发设计模式的设计思想、适用场景和工程细节,包括容量边界、失败处理和关闭流程。
并发设计模式解决什么问题
并发设计模式经常被讲成名词:生产者-消费者、Worker Thread、Future。项目里真正重要的不是背名字,而是看清楚它们分别在解决哪类压力。
一个典型场景是订单创建后要发短信、写操作日志、同步搜索索引。如果都放在请求线程里同步做,用户会被非核心动作拖慢。于是我们把任务交给后台线程处理。
但”扔到队列里异步执行”只是第一步。队列多大、满了怎么办、失败怎么重试、服务关闭时任务怎么收尾,这些才决定它能不能上线。
生产者-消费者模式
生产者负责提交任务,消费者负责处理任务,中间用队列解耦。
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1000);
// producer
queue.offer(task, 100, TimeUnit.MILLISECONDS);
// consumer
while (running) {
Task task = queue.take();
handle(task);
}
这个模式的核心不是队列本身,而是容量边界。
| 队列类型 | 特点 | 风险 |
|---|---|---|
| 无界队列 | 最省事 | 任务无限堆积,拖垮内存 |
| 有界队列 | 迫使设计边界 | 需要处理满队列情况 |
有界队列会迫使你回答业务问题:队列满了是拒绝、降级、同步执行,还是写入可靠消息队列?这些选择不能由技术默认值决定。
背压机制
生产者-消费者模式最怕”生产者永远成功”。如果生产速度持续大于消费速度,系统迟早要在某个地方失败。成熟的设计会让压力尽早反馈给生产者:
offer带超时,超时后返回失败- 队列水位超过阈值时入口限流
- 非核心任务直接丢弃或合并
- 核心任务转入可靠消息队列,后续补偿
这就是背压。它不是让用户永远等,而是让上游知道下游已经处理不过来,从而降速、降级或改变路径。
Worker Thread 模式
Worker Thread 模式把任务交给固定数量的工作线程。它的价值是限制同时执行的任务数量,避免每来一个请求就创建一个线程。
任务队列 -> worker-1
-> worker-2
-> worker-3
Java 里的线程池就是这个模式的常见落地。
这里最容易踩坑的是把不同性质的任务混在一个 worker 池里。报表导出、短信发送、订单核心处理如果共用线程池,报表一慢,订单也会被拖住。
所以 Worker Thread 要配合资源隔离:
- 核心任务和非核心任务分池
- 慢 IO 和 CPU 计算分池
- 重任务要有单独并发上限
Future 模式
Future 模式解决的是”任务异步执行,但调用方未来还要拿结果”。
Future<Result> future = executor.submit(this::queryRemote);
Result result = future.get(300, TimeUnit.MILLISECONDS);
| 特性 | 说明 |
|---|---|
| 获取结果 | get() 阻塞等待,get(timeout) 带超时 |
| 取消任务 | cancel() 尝试取消 |
| 异常处理 | 异常包装成 ExecutionException |
但 Future 也容易被写坏:
- 在请求线程里提交异步任务,然后立刻
get(),没有并行收益 - 不设超时,远程调用卡住后,请求线程和工作线程一起被拖住
如果有多个异步结果要编排,CompletableFuture 会比普通 Future 更适合;如果只是提交后台任务不关心结果,Future 反而可能是多余复杂度。
失败处理
后台任务失败后,不能只打印日志。至少要区分三种失败:
| 失败类型 | 特点 | 处理方式 |
|---|---|---|
| 临时失败 | 下游超时、连接抖动 | 延迟重试 |
| 永久失败 | 参数错误、状态非法 | 不重试,记录死信 |
| 不确定失败 | 请求超时但下游可能成功 | 查询状态或依赖幂等 |
如果任务有副作用,比如发券、扣库存、推送消息,重试前必须设计幂等键。否则 worker 重启、队列重投、手工补偿都可能制造重复执行。
进程内队列还要面对进程崩溃。只要任务不能丢,就不要只放内存队列;可以用数据库任务表、MQ、延迟队列或调度平台,让任务状态可恢复。
关闭流程
并发模式上线后,服务关闭不是直接 kill 进程。队列里可能还有任务,worker 可能正在处理一半。
一个基本关闭流程应该包括:
- 停止接收新任务
- 等待队列里已有任务处理一段时间
- 超时后取消或转存未完成任务
- 记录失败任务,方便补偿
线程池里对应的是 shutdown()、awaitTermination()、必要时 shutdownNow()。但更重要的是业务语义:短信可以丢吗,索引同步可以补吗,订单状态变更能不能中断?
常见误区
误区一:用了队列就一定削峰
队列只能缓冲,不能提高消费者处理能力。峰值超过容量太久,队列只是在延迟失败。
误区二:异步任务失败没人管
后台异常不会自动反馈给用户,如果没有日志、重试、死信或补偿,它就会静默丢失。
误区三:把并发模式当成架构方案
进程内队列只能保护当前 JVM,服务重启任务就可能丢。需要可靠投递时,要用数据库任务表、MQ 或可恢复的调度系统。
项目里怎么落地
设计一个异步处理链路时,至少写清楚:
- 生产速度和消费速度分别是多少?
- 队列容量是多少,满了怎么处理?
- worker 数量由什么资源决定?
- 任务失败后重试几次,是否需要死信?
- 服务重启时未完成任务怎么恢复?
- 是否需要把结果返回给调用方?
进程内 vs 可靠方案
| 场景 | 进程内方案 | 可靠方案 |
|---|---|---|
| 本地指标聚合 | ✅ | - |
| 短期缓存刷新 | ✅ | - |
| 非关键预热 | ✅ | - |
| 订单超时关单 | - | ✅ |
| 发券、扣减库存 | - | ✅ |
| 支付回调后续处理 | - | ✅ |
一个简单判断是:服务重启时,这个任务丢了会不会影响业务正确性?如果答案是会,就要从进程内模式升级到数据库任务表、MQ 或调度平台。