
周五晚上十一点报警群突然炸了。订单系统的超卖数据一瞬间飙到上千条数据库里库存字段出现了负数。我盯着日志里那堆交错的时间戳脑子里只有一个念头这又是一个典型的读改写竞争条件。Java后端做了这么多年synchronized、ReentrantLock、数据库锁写了一大堆可真到线上并发涌进来的时候该出的问题一个都没少。后来我在复盘时把从单机锁到分布式锁、从数据库并发控制到压测验证的全链路重新梳理了一遍才发现大多数并发问题根本不是“锁不够”导致的而是对“竞争到底产生在哪一步”没看清楚。这篇文章就把我这些年处理并发与竞争问题的完整思路和落地经验写出来包含具体代码、配置、压测方法和踩坑记录。无论你是刚接触Java并发的初学者还是已经在处理数据库并发锁、jmeter压测、甚至多摄像头高并发采集这类实战项目的开发者应该都能从中找到能直接用的东西。1. 先从一次“抢购超卖”事故说起竞争到底在哪一步发生1.1 竞态条件不是玄学一个读改写流程的逐步拆解超卖这类事故几乎都能归结到同一个基础的竞态模式上读改写。流程看起来简单——先读取库存判断库存是否大于0然后扣减库存最后写回数据库。但在并发场景下两个请求可能同时读到同一个库存值同时通过判断同时写回最后就出现了超卖。我把当时的内存里经过简化后的代码贴出来你感受一下public void deductStock(Long skuId, int count) { int stock stockMapper.getStock(skuId); if (stock count) { throw new BizException(库存不足); } stock stock - count; stockMapper.updateStock(skuId, stock); }这段代码在单线程下没有任何问题。但一旦有多个线程同时执行getStock这步会并发进行。假设库存只剩1件A请求和B请求同时读到1两个请求都判断“1≥1”成立然后各自减去1最后各自写回0。结果就是同一件库存被卖出去两次。关键点在于问题不是出在判断逻辑上而是出在“读”和“写”之间那个时间窗口。线程调度、网络IO、数据库事务提交延迟都会把这个窗口拉大让竞争发生的概率变得极高。理解了这一点你要做的就不是盲目加锁而是锁定读改写这三个动作让它们在并发场景下具备原子性。1.2 并发与并行先搞清楚你在解决哪个问题很多人把并发和并行混在一起讨论但这两个概念在网络热词里出现的频率非常高说明大家确实容易混淆。并发Concurrency是逻辑上的同时发生多个任务交替执行并行Parallelism是物理上的同时执行多个任务真正在不同核心上并行跑。单核CPU上也可以有并发但不可能有并行多核CPU上两者可以兼得。这个区别特别重要因为解决并发竞争的思路跟解决并行性能瓶颈的思路完全不同。竞争问题的核心是共享状态的一致性而并行性能问题的核心是用满多核资源。你可以在一个线程池里设置20个线程处理任务这叫并发配置但如果机器只有4个核真正的并行度其实只有4左右。调优时用jmeter压测系统并发数首先要明白你压的是并发处理能力不是物理并行能力这两个指标的调整手段和瓶颈位置完全不同。理解了这一点后续很多方案就好选了。比如模块内部用无锁设计解决竞争问题时你关注的是同一个共享变量会不会被多个线程同时改而设计多摄像头并发采集流水线时你关注的是GPU推理能不能跟多路IO并行起来。一个是为正确性服务一个是为吞吐量服务别混在一起调。1.3 竞争问题的三大来源共享状态、复合操作、调度切换根据我处理过的线上问题几乎所有竞争问题都能归到三个来源第一是共享状态。多个线程访问同一个变量、同一个对象、同一条数据库记录。比如库存字段、计数器、配置缓存这些都是高发区。第二是复合操作。一个业务操作由多个步骤组成步骤之间不是一个原子动作。读改写是最典型的还有“检查再写入”“先查再创建”“先删后插”也都是。这类问题最隐蔽因为代码看起来是顺序的实际却是并发的。第三是调度切换。线程在执行过程中被操作系统切走恰好另一个线程也进入了同一段代码。即使你的代码逻辑很正确调度器也会在任意位置插入切换点。用好锁的本质就是告诉调度器和JMM这段代码不允许交错执行。除了这三个来源还有一个间接因素是数据库隔离级别。比如在MySQL默认的RR隔离级别下普通查询是快照读不会阻塞写操作但当前读会加锁。很多人没搞清楚读锁和写锁的区别导致加锁方案设计错位后面我会专门说数据库锁的部分。2. 单机进程内的第一道防线锁、原子操作与线程协作2.1 互斥锁的正确用法从synchronized到ReentrantLock解决进程内竞争最基本的工具就是互斥锁。Java里两类主流选择synchronized关键字和JUC的ReentrantLock。很多人问到底用哪个好我的判断标准很简单能满足需求时优先用synchronized因为它用法简单、不需要手动释放、JVM会做锁消除和偏向锁优化当你需要超时等待、可中断、多个条件队列、公平锁这类高级功能时再换ReentrantLock。用synchronized把刚才的库存扣减包起来public synchronized void deductStock(Long skuId, int count) { int stock stockMapper.getStock(skuId); if (stock count) { throw new BizException(库存不足); } stock stock - count; stockMapper.updateStock(skuId, stock); }这段代码在一个JVM进程内是安全的。但请注意synchronized锁的是当前对象实例如果不是单例对象或者方法被不同实例调用锁会失效。所以我更习惯把锁对象单独抽出来private final ReentrantLock lock new ReentrantLock(); public void deductStock(Long skuId, int count) { lock.lock(); try { int stock stockMapper.getStock(skuId); if (stock count) { throw new BizException(库存不足); } stock stock - count; stockMapper.updateStock(skuId, stock); } finally { lock.unlock(); } }记住几个要点锁要覆盖完整的读改写区间锁对象必须是被所有竞争线程共享的unlock必须放在finally里否则一旦业务抛异常锁就永远不释放直接把系统打成死锁。这三点我在面试时几乎每次都会问也是实践中新手最容易犯的错。2.2 读写锁与锁粒度为什么说“锁越粗越安全越细越难”互斥锁的缺点是读与读之间也互斥。但实际业务中读多写少才是最普遍的场景比如配置表、商品信息、模型参数。这种时候用互斥锁就是白白损失性能。读写锁的核心逻辑读读不互斥读写互斥写写互斥。Java里就是ReentrantReadWriteLock或者性能更好的StampedLock。使用读写锁需要注意锁升级的问题。很多人在读锁里尝试获取写锁这在ReentrantReadWriteLock里是不允许的会死锁。我一般建议在需要“先查再改改完再返回”的场景直接用写锁或者用StampedLock的乐观读来做乐观更新别在锁内部绕来绕去。锁粒度是另一个常被忽视的问题。用同一把锁锁住所有库存操作实现简单但性能会快速劣化因为不同SKU的扣减会互相阻塞。更合理的设计是按SKU粒度加锁比如引入一个锁分段或者用ConcurrentHashMap维护每个业务ID对应的锁对象private final ConcurrentHashMapLong, Object lockMap new ConcurrentHashMap(); public void deductStock(Long skuId, int count) { Object lock lockMap.computeIfAbsent(skuId, k - new Object()); synchronized (lock) { // 读改写扣减逻辑 } }这里要注意lockMap会不断膨胀需要提供清理机制比如在扣减完成后判断是否仍有线程持有没有就remove掉。锁粒度越细并发能力越强但管理成本也越高这就是为什么说“锁越粗越安全越细越难”。2.3 CAS与原子类无锁方案在什么场景下才是真香锁能解决竞争但阻塞和唤醒是有代价的。对于单一的数值类竞争场景比如计数器、序列号、并发标记位用CASCompare And Swap是无锁方案的首选。Java里的AtomicInteger、AtomicLong、LongAdder都是基于CAS实现。CAS的核心是“比较并交换”只有当前值和预期值一致时才写入新值这个动作由CPU指令保证原子性。不过CAS有个著名的问题叫ABA问题线程A读到值为1线程B把值改成2又改回1线程A比较时发现还是1就认为没被改过。解决方法是给值加版本号Java里AtomicStampedReference就是干这个的。但实际业务中纯ABA问题并不常导致数据错误因为单靠CAS本来就只能解决单一变量的原子更新真正复杂的复合操作还是需要锁或其他机制。CAS适合的场景是竞争字段是单一数值、更新频率高、且不需要同时更新多个字段。比如统计接口调用次数、生成自增序列、多线程累加指标。如果既需要扣减库存又需要记录扣减流水那CAS就不好使了你得用数据库事务或者分布式事务来保证多对象一致性。我一直强调无锁不是银弹它只是把竞争压力转移到了CPU指令层面高频冲突时CAS的自旋开销也不小。2.4 线程池和Agent多并发配置中的隐性竞争不少系统里的竞争不是出现在业务代码里而是出现在线程池和框架配置层。比如一个线程池的任务队列是无界的任务一多内存就涨又比如多个Agent并发执行采集任务时同一个输出文件被多个Agent线程同时写入。我在调优Agent多并发配置时原则是把线程池隔离到不同职责域IO密集型线程数设置为核心数×2左右CPU密集型的设置为核心数1阻塞队列一定要有界拒绝策略要明确。场景再具体一些配置中心或者任务调度中心下发并发任务时多个Agent同时去消费同一个任务分片容易出现重复执行。这就要在Agent侧做幂等要么任务表里加状态位用数据库行锁保证只有一个Agent能抢到处理权要么在任务执行前加分布式锁执行完成后释放。否则你会发现并发越高重复执行的脏数据越多而这往往被误以为是系统性能不行其实是竞争控制没做好。3. 数据库层的并发控制行锁、乐观锁和悲观锁的选型逻辑3.1 数据库并发锁怎么加才不锁错地方业务越往后做进程内的锁就越不够用了。因为一个服务基本都会多实例部署进程内的锁管不住其他JVM的线程。这时候并发控制必须下沉到数据库。先说最基础的数据库行锁。在InnoDB里对一行记录执行update、delete、select ... for update时会自动对命中的索引记录加锁。如果你的更新语句能精确命中唯一索引那并发控制就是按行粒度互斥的。但这里有个坑如果更新条件没有索引InnoDB会退化成锁整张表。曾经有个同事在扣减库存时用了一个不带索引的字段做条件结果并发一上来数据库瞬间锁表所有写操作全堵住。后来把SQL改成按主键或唯一索引执行加锁范围立刻缩小到单行性能恢复。经验是数据库并发锁方案里索引字段的设计直接决定了锁粒度凡是需要锁住的记录查询条件必须能通过索引精确定位。3.2 乐观锁和悲观锁业务场景决定你选哪个数据库层面的并发控制策略业界分成乐观锁和悲观锁两条路线。悲观锁就是靠数据库的行锁执行select ... for update把记录锁住然后做业务操作最后update提交。好处是强一致坏处是并发性能差锁等待多还可能出现死锁。乐观锁则不用数据库锁而是在表中加一个version字段。更新时把version作为条件update sku_stock set stock stock - 5, version version 1 where sku_id 123 and version 12;这条SQL执行后返回的影响行数如果是1说明更新成功如果是0说明版本不匹配有其他线程改过了需要重试或者提示用户。乐观锁适合并发冲突概率低、读多写少的场景比如商品详情更新、个人资料修改。悲观锁适合冲突概率高、必须严格排队执行的场景比如特卖库存扣减、账户余额扣款。我在实际选型时还有一个补充判断看业务对失败重试的容忍度。乐观锁遇到冲突需要重试重试逻辑对用户体验有影响悲观锁是让请求排队等待体验相对平滑代价是数据库连接占用时间长。这两种方案没有绝对优劣完全看业务形态。3.3 并发上传与幂等控制接口层如何配合数据库锁并发上传这个场景在热搜词里反复出现说明很多人都在处理类似问题。用UMI OCR或Agent并发上传文件时最常见的竞争点是“同一个文件被多个请求同时上传”或者“上传回调与主流程同时操作同一单据”。这类问题光靠OSS或对象存储本身解决不了因为对象存储只管文件数据业务状态还得自己控制。我的做法是在接口层加幂等键。客户端生成一个全局唯一请求ID服务端根据这个ID去查是否已经处理过。如果处理过就直接返回结果没处理过就执行上传逻辑。判断“是否处理过”要用数据库唯一索引兜底比如上传记录表里给request_id建唯一索引插入时如果冲突就说明重复请求。这样即使并发请求同时进来数据库的唯一索引也能强制保证只有一个成功。再配合乐观锁更新单据状态update upload_doc set status PROCESSED, file_url #{fileUrl} where id #{docId} and status PENDING;这样做的核心思路不要试图在应用层用锁去拦截所有并发把最终一致性交给数据库约束去兜底。数据库唯一索引和行锁是最可靠的防线应用层锁再牛也挡不住多实例之间的时间差。3.4 死锁的产生条件与常见解法数据库并发锁用得多了死锁基本躲不掉。死锁的四个必要条件互斥、持有并等待、不可剥夺、循环等待。MySQL的innodb_lock_wait_timeout默认50秒超过会报错回滚。但死锁更让人头疼的是它不一定稳定复现可能在某个峰值流量下才突然出现。我遇到过的典型死锁场景是两个事务同时先锁A再锁B和先锁B再锁A。解决思路无非几种第一所有事务都按相同的顺序访问资源比如先按主键排序再更新第二尽量缩短事务时间不要把远程调用放到锁区间里面第三利用MySQL死锁检测机制让其中一个事务回滚并重试。Caused by: java.sql.SQLException: Deadlock found when trying to get lock; try restarting transaction——看到这个报错先别慌先看死锁日志里的两个SQL顺序调整代码让锁顺序一致80%的死锁都能解决。4. 跨进程与分布式环境分布式锁的落地与避坑4.1 为什么单机锁在集群里会失效当服务从单机扩展到多实例后单机锁失效的根本原因有两个一是多个进程之间无法共享Java内置锁的对象监视器synchronized锁住的实例对象在各个JVM里是独立的副本二是即使你用了数据库层面的乐观锁也解决不了“在业务代码中先处理一段逻辑再更新数据库”这类需要跨步骤互斥的场景比如定时任务分布式调度、多实例抢单、缓存更新。这些场景必须引入跨进程的分布式锁。4.2 Redis分布式锁和Redisson的可靠性边界最常用的分布式锁实现是Redis的SETNX。简单基于SET NX EX的锁问题不少锁超时误删、持有者不匹配等。我现在一般直接用Redisson的RLock它实现了可重入锁并且内部有看门狗机制自动续期避免业务没执行完锁就超时释放了。RLock lock redissonClient.getLock(order:pay: orderId); boolean isLocked false; try { isLocked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!isLocked) { throw new BizException(操作太频繁请稍后重试); } // 业务逻辑 } finally { if (isLocked lock.isHeldByCurrentThread()) { lock.unlock(); } }需要记住的一点如果加锁和释放锁不在同一个线程Redisson会报IllegalMonitorStateException。还有Redis分布式锁要保证可靠性前提是Redis本身是高可用的如果主节点挂了且数据没有同步到从节点锁可能会丢失。对于极高一致性的金融类业务可以考虑Redisson的RedLock或者直接上数据库唯一约束但RedLock本身也有争议我的建议是先想清楚业务能不能接受极端情况下的重复执行如果不能那就别过度依赖分布式锁把幂等设计放在数据库层面更靠谱。4.3 锁超时、可重入和时钟跳跃分布式锁的经典坑分布式锁的经典坑总结下来有三个。锁超时导致并发进入临界区。如果业务耗时大于锁的过期时间锁会自动释放其他请求就能进来。解决办法是用Redisson看门狗续期或者把这个锁的超时时间设置成业务最大耗时的数倍。可重入问题。同一个线程在一个流程里多次加同一把锁如果锁实现不可重入就会自己锁死自己。Redisson的RLock默认可重入但自己用SETNX实现的锁需要维护一个计数器复杂度会高很多。时钟跳跃问题。Redis的过期时间依赖服务器时钟如果发生时钟回拨锁的过期时间计算会出问题。规避方式是用Redisson的周期性续期机制而不是单纯靠expire过期。分布式锁不是银弹每引入一个分布式组件都是给系统增加一个故障点。能用数据库唯一索引解决的就别上Redis锁能用Redis锁解决的就别引入ZooKeeper。5. 用jmeter压测来验证并发方案并发数怎么确定、结果怎么读5.1 怎么确认系统的真实并发数我经常被问到“系统并发数怎么确认”其实并发数不是产品经理拍脑袋定的也不是参考别人系统抄来的而是通过压测摸出来的。jmeter里面线程数代表模拟用户数但这里的并发数要分两层请求并发数和有效并发数。请求并发数是同时发出的HTTP请求数有效并发数是被服务端同时受理并处理的请求数。如果服务端线程池满或者数据库连接池满大量请求其实是排队等待的实际并发处理能力反而更低。我的方法先用jmeter设置一组递增的并发数比如50、100、200、400每个档位运行一段时间记录TPS、平均响应时间、错误率。当TPS出现明显拐点不再跟着并发数增长甚至开始下降或者错误率突然飙升时这个并发数就是系统的有效上限。5.2 压测脚本设计与线程组策略用jmeter做压测我习惯按这四步设计脚本第一步明确压测目标。是要测单接口的并发上限还是测一个完整业务链路两个目标对应的测试脚本和监控指标完全不同。第二步配置线程组。我是这样设置的线程数从低到高分梯度压不直接拉到很大的数。Ramp-Up Period设置为线程数除以目标每秒启动数比如50线程、每秒启动5个那Ramp-Up就设10秒。这样让系统有一个预热过程避免冷启动把结果带偏。第三步加定时器。如果要做更真实的模拟可以用Constant Throughput Timer控制吞吐量或者在脚本里加思考时间。但如果你要测的是系统极限就不要加这些直接压。第四步加断言和监听器。断言用来判断响应是否符合预期监听器重点看聚合报告和TPS曲线。我还会在服务端同时监控CPU、内存、磁盘IO和数据库连接池状态不然压测结果出来了但定位不到瓶颈点。5.3 从压测结果反推竞争热点压测不只是看一个总TPS更要通过结果反推出系统里的竞争热点。如果并发数升高后CPU不高但TPS上不去大概率是锁等待、数据库连接池等待或者线程池排队。如果CPU已经打满那瓶颈在计算密集逻辑该考虑的是并行优化而不是加锁。有一次我压一个下单接口并发从50升到200TPS涨到一半就不动了。看线程dump发现大量线程阻塞在synchronized锁上而那个锁保护的其实只是一段读取缓存的逻辑。优化方式很直接把这段代码改为读写锁读请求不再互斥TPS立刻涨了近一倍。压测的价值就在这里它不是为了得到一个好看的数字而是帮你在上线前找出这些在正常流量下根本暴露不出来的竞争点。6. 实战复盘YOLOv8工厂缺陷检测系统的多摄像头并发架构6.1 多路摄像头并发采集的任务分解与流水线这是一条比较新的实战场景基于YOLOv8的工厂缺陷检测系统多摄像头并发接入。这类系统遇到的并发竞争跟传统Web后端还不太一样它的复杂点在于不仅要处理网络请求并发还要管理多路视频流的数据同时到达、推理任务并发执行、结果写回的统一协调。我设计的流水线分四段采集、预处理、推理、结果入库。每个阶段之间用有界队列连接每个阶段分配独立的线程池。这样一个摄像头的采集卡顿不会直接阻塞所有推理任务多个摄像头的推理结果也不会因为写库阻塞而互相等待。关键点是要给每路摄像头一个独立的session ID所有环节通过这个ID关联避免多路数据在流水线里串线。6.2 瓶颈到底卡在推理还是IO一次索引竞争排查在这个系统运行初期我们把四路摄像头接入后发现整体检测FPS远低于单路单独运行的总和。一开始怀疑是GPU推理速度不够后来一查才发现问题出在结果入库环节。多路推理结果同时写MySQL既更新检测表又更新统计表而统计表上的并发更新没有按摄像头维度分开导致同一行记录被四路结果同时竞争。解决办法是把统计逻辑改成按摄像头维度分片每个摄像头维护独立的统计行最后需要整体统计时再做汇总。这是典型的数据库热行竞争问题本质上是共享写热点。排查过程给我最大的启发是并发系统的性能瓶颈常常不在你最直觉认为是核心计算的地方而在那些看起来不起眼的汇聚节点上。6.3 并发配置参数的调优过程参数调优没有一步到位的事我给这个系统最终确定的参数是这样的摄像头采集线程数设为摄像头数量的1.5倍左右多出来的线程用于处理重连和重试。推理线程池设为核心线程数等于GPU可以同时执行的批次数不建议开太多线程因为YOLOv8推理在单GPU上本来就是个串行为主的流程线程太多反而增加上下文切换开销。预处理和结果入库的队列都设为有界队列队列满时采取丢弃策略并触发告警防止内存被打爆。调优后四路摄像头的有效FPS从原来的不到10提升到接近单路的4倍。这个项目的核心收获是并发解决的不只是锁冲突问题还有资源调度和流水线均衡的问题。前者保证不犯错后者保证跑得快两者缺一不可。在最终按下压测启动按钮或者上线发布之前我建议你再多花十分钟确认三件事锁的覆盖范围是否完整地包住了读改写区间数据库的索引是否支撑了你的锁粒度分布式锁的超时设置是否符合业务的实际耗时。这三件事是并发系统里最常见的翻车点也是一次次线上事故之后我总结出的最小检查清单。