写完接口一压测就超时、跑批任务越跑越慢、偶发数据错乱复现不出来——这些卡点多半不是语法问题,而是线程模型、共享状态和资源边界没设计好。这篇不讲语言层面的入门语法,只讲工程里怎么把并发写稳:线程池与任务切分、共享状态与幂等、压测与观测、超时与降级,最后给一套线上出问题的排查顺序。
写完接口一压测就超时、跑批任务越跑越慢、偶发数据错乱复现不出来——这些卡点多半不是语法问题,而是线程模型、共享状态和资源边界没设计好。这篇不讲语言层面的入门语法,只讲工程里怎么把并发写稳。
只要你需要给后端和测试同学讲清楚落地写法,就按下面这条线走:先定线程模型和池参数,再处理共享状态与幂等,接着量出并发能力、补上超时与降级,最后留一套线上排查顺序。下面每一节都只给判断标准和可执行动作,不给绝对值。
先定线程模型,再谈线程池参数
很多人上来就调池参数,其实第一步是判断任务性质。CPU 密集的任务把线程数压到核数附近就够,再多只是增加上下文切换;IO 密集的任务等待时间占大头,才允许线程数远超核数。具体给多少,先按类型定区间,再去站内核对当时环境的实测数据,别照抄固定数字。
- 核心线程数
- 池里常驻的线程数量,决定平时能并发处理多少任务
- 最大线程数
- 队列满后允许临时扩到的上限,用来吸收突发流量
- 队列容量
- 等待执行的任务缓冲区,容量决定背压什么时候才反馈给调用方
- 拒绝策略
- 队列和线程都满时怎么办,是抛异常、丢任务还是交给调用方跑
- 线程命名
- 给线程起可识别的名字,否则后面日志里根本区分不出谁在干活
- 先判断任务是 CPU 密集还是 IO 密集,两类任务的线程数量级完全不同
- 线程池四件套一次定齐:核心与最大线程数、队列类型与容量、拒绝策略、线程命名
- 别用无界队列兜底,它会把背压问题藏起来,最后表现成内存涨和响应变慢
- 任务粒度按单批耗时切分:太碎锁和调度开销吃掉收益,太大一个慢任务卡住整条队列
- 给池子设监控点:活跃线程数、队列深度、拒绝次数,这三个数没有就别谈调优
共享状态、锁与幂等:并发错误高发区
并发 bug 九成出在共享可变状态上。先把真被多线程读写的变量列出来,再逐个决定处置方式:能改成不可变对象就改,能用局部变量或线程封闭就隔离,实在要共享才加锁。这一步做完,能省掉大量后续的锁竞争问题。
| 状态类型 | 处置方式 | 什么情况下不适用 |
|---|---|---|
| 只读配置 | 不可变对象,启动时加载 | 配置需要运行期热更新时改用原子引用 |
| 线程内计数器 | 局部变量或线程封闭 | 需要跨线程汇总时改用并发容器 |
| 共享计数/累加 | 原子类或并发容器 | 需要多个字段一起保持一致性时改用锁 |
| 数据库记录 | 乐观锁版本号或条件更新 | 热点行冲突极高时可考虑串行化或分片 |
| 写操作重试 | 唯一键、去重表或请求 ID | 业务本身允许重复写入时不必加去重 |
加锁有个基本纪律:只保护临界区,不要把 IO、网络调用、远程请求塞进锁里。锁粒度一大,等于把并发改回串行,还顺带把下游的延迟算进了锁等待里。数据库层面同理,并发改同一条记录优先用条件更新,别只靠应用层加锁。
重试和并发天生一起出现,所以写操作必须幂等。唯一键、去重表、请求 ID 三选一,否则一次超时重试就是一次重复下单。判断标准很简单:同一个请求发两次,结果必须和发一次一样;做不到这一点的接口,先别加重试。
压测与观测:并发数要量出来,不是猜出来
- 先量基线单请求串行压,记录延迟基线区间,后面所有结论都相对这个基线看
- 再按梯度加压按并发梯度逐档加,比如从低到高分几档,每档跑够稳定时间再记录
- 记录拐点找到吞吐不再涨、延迟开始陡升的那一档,那才是真实并发能力
- 看错误爬升同时记录错误率,错误率抬头的位置通常就是保护机制该生效的位置
压测不是只看 QPS。要同时关注四类指标:吞吐、P95/P99 延迟、错误率、资源水位(CPU、内存、连接数、线程池队列深度)。只看平均值会漏掉尾延迟,很多线上超时就是被少数慢请求拖出来的。基准数字到站内工具页跑一遍当时的接口速度更有参考价值。
- 日志里至少要能还原一次请求:请求 ID、线程名、排队耗时、执行耗时、重试次数
- 偶发问题靠日志不靠复现,把线程名和请求 ID 打通才分得清是排队久还是执行慢
- 给线程池和连接池都加队列深度监控,排队深度比平均延迟更早报警
- 压测环境和生产环境的核心参数要保持一致,不一致的压测结论只能当参考
超时、限流与降级:把最坏情况兜住
并发能力再强,也要给最坏情况兜底。第一件事是把连接超时和读取超时分开设,读超时按下游正常延迟加余量,不要一个统一的大超时通吃所有调用——那样只会让线程被长时间占住,把局部问题放大成整体雪崩。
| 保护手段 | 怎么设 | 适用判断 |
|---|---|---|
| 连接超时 | 按网络往返量级设短一些 | 下游不可达时快速失败,别让线程卡在握手 |
| 读取超时 | 按下游正常延迟加余量 | 下游延迟波动大的接口要留更宽余量 |
| 重试 | 只对可重试错误,指数退避加抖动,设次数上限 | 写操作重试前先确认幂等 |
| 限流 | 按下游容量设阈值,触发后快速拒绝 | 自身是瓶颈时优先限自己出口 |
| 降级 | 返回缓存、兜底值或异步化 | 关键路径必须有降级开关 |
重试要克制:下游并发限制触发后按指数退避加抖动重试,次数设上限,并且只对可重试的错误重试。重试本身也是流量,发得太猛只会把下游推得更远。降级开关要提前想好触发条件,是自动触发还是人工拨,先保证不雪崩再谈恢复。
上线前还有一件事:想好转回去的办法。线程池参数和限流阈值都要能热改或有明确回滚项。如果改一个并发参数只能靠重启,那这套配置在故障时基本等于不可用。这类可运维性判断,可以顺带看看站内对中转服务的稳定性检查思路,逻辑是相通的。
线上并发问题排查顺序
线上出并发问题,最容易犯的错是上来就翻代码。正确做法是按顺序缩小范围,四步走完基本能定位到方向。顺序不要跳,跳步容易在无关方向上耗时间。
- 先看错误类型超时、拒绝、限流、数据错乱,四类对应完全不同的方向,先分类再动手
- 再看资源线程池队列深度、活跃线程数、连接池占用、GC 情况,判断是排队还是执行慢
- 然后看依赖下游接口延迟是否同步上涨,确认是自身问题还是被下游拖住
- 最后看数据只有涉及写操作和数据错乱时,才翻数据库锁等待与版本冲突记录
有些场景不适合硬上多线程:单机低并发、任务本身强顺序依赖、下游是不可并发的第三方接口。这几种情况硬上多线程只会放大问题,不如老老实实串行加超时控制。判断标准是:加并发换来的吞吐提升,能不能覆盖掉它引入的复杂度和故障面。
下一步把上面的池参数、幂等键和排查顺序落到你自己的服务里,再拿站内工具页的测速和价格追踪数据对照当时的实际环境,会比凭感觉调参稳得多。→常见问题
线程数到底该开多少,有没有一个通用公式?
没有能照抄的通用值。判断顺序是:先看任务类型(CPU 密集还是 IO 密集),再看下游能承受多少并发。CPU 密集从核数起步试探,IO 密集按「核数 × 单个任务等待占比」估算再实测收敛。任何公式都只是起点,最终以压测出来的吞吐拐点为准,具体基线可以到站内接口测速页量一遍。
并发写数据库经常出现重复数据,怎么防?
优先在数据库层解决:唯一索引或去重表挡住重复写入,更新用版本号做乐观锁或带条件的原子更新,别指望应用层加锁。再配合幂等设计——每个写请求带唯一请求 ID,服务端落库前查一次。注意这套只在写路径有效,纯查询接口不需要。
本地压测都正常,上线就超时,从哪查起?
先对比环境差异:实例规格、连接池上限、下游实例数和网络延迟。常见原因是本地并发没真正压起来、或者下游在线上是共享资源被别的业务占满。排查按「错误类型 → 线程池与连接池水位 → 下游延迟 → 日志还原单次请求」的顺序走,别一上来就改线程数。超时参数怎么分档设,可以对照站内超时配置那套思路。