高并发下写竞争解决方案:CAS与低延迟读写架构实践 1. 从一次线上事故说起当“库存”变成“烫手山芋”去年双十一我负责的一个核心交易系统差点崩了。那是一个典型的秒杀场景我们为某个热门商品准备了10万件库存。活动开始瞬间流量洪峰涌来监控面板上的QPS瞬间冲到了每秒5万次。理论上我们的服务集群完全能扛住这个量级的读请求。但诡异的事情发生了活动开始不到3秒后台显示库存瞬间被“超卖”了2万多件而实际成功下单的用户远少于这个数。更糟糕的是后续大量用户的“立即购买”请求变得极其缓慢部分甚至直接超时失败。事后复盘根因直指一个我们当时认为“很简单”的问题写竞争Write Contention。我们使用了一个简单的“查询库存 - 判断 0 - 库存减1 - 更新数据库”逻辑。在每秒数万次请求下成千上万个请求几乎同时读到同一个库存值比如1000都判断为大于0然后都去执行减1更新。数据库的锁机制如行锁在如此高强度的竞争下变成了性能瓶颈和单点。大量更新操作排队等待锁释放导致更新延迟飙升。而由于更新是顺序执行的后到的更新基于陈旧的“1000”进行减1最终导致库存被减到了负数这就是“超卖”。同时堆积的写请求耗尽了数据库连接拖慢了整个服务这就是“低延迟”的彻底沦陷。这次惨痛的教训让我深刻意识到在高并发场景下处理“写竞争”不是可选项而是生死线。它直接决定了你的系统是丝滑流畅还是瞬间雪崩。今天我们就来深入聊聊如何通过构建低延迟的读写方案与善用CASCompare-And-Swap寄存器这一硬件原语思想来优雅地解决这个难题。2. 写竞争的本质为什么简单的“读-改-写”会崩盘在深入解决方案之前我们必须先理解敌人。写竞争并非高并发独有的问题但在高并发下其破坏力会被指数级放大。2.1 “读-改-写”操作的非原子性陷阱我们业务中绝大多数写操作本质上都不是一个单纯的SET x 1而是一个“读-改-写”Read-Modify-Write, RMW的复合操作。就像上面的库存扣减读ReadSELECT stock FROM items WHERE id 123- 得到stock 1000。改Modify在应用内存中计算new_stock 1000 - 1 999。写WriteUPDATE items SET stock 999 WHERE id 123。在单线程或低并发下这个流程完美无缺。但在高并发下多个线程/进程可能交错执行这三个步骤。线程A和线程B几乎同时读到stock1000各自在内存中计算得到999然后先后去更新数据库。无论谁先更新成功后一个更新都会基于过时的数据1000进行覆盖导致一次扣减被丢失。这就是丢失更新Lost Update问题。2.2 锁机制的代价安全与性能的悖论最直观的解决方案是加锁。我们可以用数据库的行锁如SELECT ... FOR UPDATE或者在应用层用分布式锁如Redis。这确实能保证“读-改-写”的原子性将一个复合操作变成“原子操作”。但锁的代价极高串行化瓶颈锁强制让并发的请求排队串行执行。在高并发场景下这等于主动放弃了系统的并发处理能力形成长队列延迟Latency急剧上升。资源消耗持锁等待会占用数据库连接、线程等宝贵资源容易引发资源耗尽。死锁风险复杂的业务逻辑可能涉及多把锁死锁概率大增。可伸缩性差锁服务本身如数据库、Redis可能成为新的单点瓶颈。因此在高并发追求低延迟的场景下“加锁”往往是最先被排除的方案。我们需要的是无锁Lock-Free或乐观的并发控制机制。2.3 状态共享的代价缓存一致性难题为了追求低延迟读我们普遍会使用缓存如Redis。常见的“Cache-Aside”模式是读时先读缓存缓存没有则读数据库并回填写时先更新数据库再删除缓存。但在高并发写竞争下这个模式会遭遇严峻挑战并发写导致缓存脏数据线程A更新数据库stock999但尚未删除缓存线程B此时读取缓存拿到旧值stock1000。缓存删除竞争如果采用“先更新数据库再删除缓存”在超高并发下缓存删除命令可能乱序到达或者后发的删除先执行依然可能导致脏数据短暂存在。缓存击穿当缓存恰好失效时大量写竞争请求会同时去查询数据库并尝试更新对数据库造成瞬间压力。这些问题都指向一个核心我们的“读”和“写”视图在高速变化下很难保持瞬间一致。强一致性往往意味着高延迟而低延迟又可能牺牲一致性。我们需要在这之间找到适合业务场景的平衡点。3. 构建低延迟读写架构分离与缓冲的艺术要降低延迟核心思路是“减少争用”和“缩短路径”。直接怼着共享数据库行进行“读-改-写”是争用最激烈、路径最长的做法。我们必须进行架构层面的改造。3.1 写操作异步化与批量合并对于库存扣减、计数器递增这类可以接受最终一致性的写操作一个强大的武器是异步化与批量合并。思路不直接更新中心数据库而是先将写请求放入一个高速、低延迟的消息队列如Kafka、RocketMQ或内存队列如Disruptor。然后由一个或少数几个消费者线程异步地从队列中取出多个请求进行批量合并处理再一次性更新数据库。举例库存扣减生产者业务逻辑收到扣减请求不操作DB只生成一条消息{item_id:123, delta:-1}发送到Kafka的对应分区按商品ID分片保证同一商品消息有序。消费者库存处理器消费该分区的消息在内存中维护一个Mapitem_id, current_stock并累加delta。每隔100毫秒或累积100条消息将内存中聚合后的库存结果批量写入数据库。读操作读请求不直接读数据库而是读“缓存视图”。这个视图由消费者在更新数据库后同步更新到Redis缓存。或者更激进一点读请求直接查询消费者内存中的那个Map如果架构允许。优势写延迟极低业务线程只需发消息到本地或内存队列耗时在微秒级。大幅减少DB争用将成千上万的并发更新合并成少量的批量更新DB压力骤降。天然批处理提升了数据库IO效率。挑战与注意事项注意此方案适用于可接受短暂时间如几百毫秒最终一致性的场景。需要精心设计消息的可靠性投递、消费者故障恢复、以及库存防超卖需在消费者内存聚合逻辑中判断库存不能为负。实操心得消息队列的分区键选择至关重要。必须确保同一资源的更新如同一个商品ID进入同一个分区否则顺序无法保证会导致数据错乱。此外消费者的处理速度必须跟上生产速度否则队列会堆积延迟会从“写DB延迟”转化为“消息处理延迟”。3.2 读操作多级缓存与读写分离对于读延迟目标是让绝大多数请求根本“碰不到”数据库。客户端缓存Client Cache对于极少变化的静态数据或用户维度的数据直接在客户端如浏览器、APP缓存。这是延迟最低的方案0网络延迟。CDN缓存对于静态资源、热点文章等使用CDN边缘缓存。反向代理缓存在Nginx等反向代理层对接口响应进行缓存需谨慎适用于非个性化GET请求。应用层缓存如Redis/Memcached这是最核心的环节。采用优化的缓存模式Write-Through直写同步更新缓存和数据库。写延迟较高但缓存一致性最好。在高并发写场景下不适用。Write-Behind后写异步更新数据库可配合3.1的队列方案。写缓存很快但存在数据丢失风险。对于高并发写更推荐“更新DB异步失效/更新缓存”。结合消息队列数据库更新后发出一个事件由一个独立的服务来异步更新缓存避免业务线程阻塞在缓存操作上。读写分离将数据库主库只用于写和强一致性读建立多个从库用于绝大部分的读请求。配合数据库中间件如ShardingSphere、MyCat可以自动路由。构建缓存视图对于复杂的聚合查询如商品详情页需要商品信息、库存、价格、促销等不要分别查多个缓存再组装。应该在写发生时就通过监听数据变更事件Binlog CDC利用流处理技术如Flink实时构建一个面向查询的、聚合好的“物化视图”并存入Redis。读请求一次查询就能拿到所有数据。4. CAS寄存器的思想从硬件原语到软件实践CAS是解决写竞争的“原子操作”理想模型。在CPU指令层面CAS操作是原子的它比较某个内存位置的值是否与预期值相同如果相同则将该位置更新为新值否则不做任何操作。整个操作在硬件层面不可分割。4.1 软件世界的CAS乐观锁与无锁编程我们无法在应用层直接操作硬件CAS指令但可以模拟其思想实现乐观锁Optimistic Concurrency Control, OCC。核心流程读取并记录版本读取目标数据同时记录一个版本号version或时间戳timestamp。本地修改在业务逻辑中计算新值。尝试提交CAS操作执行更新语句条件是WHERE id ? AND version ?或WHERE stock ?旧值。如果条件成立说明在此期间没有其他修改更新成功同时更新版本号如果条件不成立受影响行数为0说明发生了写竞争更新失败。SQL示例-- 先读取 SELECT stock, version FROM items WHERE id 123; -- 假设读到 stock1000, version10 -- 业务计算后尝试更新 UPDATE items SET stock 999, version version 1 WHERE id 123 AND stock 1000; -- 或者 AND version 10 -- 检查 affected_rows如果为1则成功为0则失败需重试。优势无锁不存在长期持有的锁失败者直接重试即可不会阻塞其他请求。高并发在冲突不频繁的场景下性能远高于悲观锁。劣势与适用场景高冲突下性能差如果写竞争非常激烈如热点商品秒杀大量请求会不断失败重试称为“CAS失败风暴”消耗CPU资源体验不佳。此时它可能不如队列合并方案。适用于冲突率较低的场景如用户余额扣减同一用户并发支付请求较少、文章点赞计数、配置更新等。4.2 原子操作与分布式CAS许多现代数据存储系统提供了内置的原子操作这本质上是系统帮你实现的CAS。Redis的原子命令INCR/DECR原子递增/递减。这是实现计数器的黄金标准。HINCRBY哈希字段原子递增。SETNXSET if Not eXists原子性地实现分布式锁。Lua脚本将多个操作打包成一个原子脚本执行。这是实现复杂RMW原子操作的利器。-- Lua脚本原子扣减库存防止超卖 local key KEYS[1] -- 商品库存key local change tonumber(ARGV[1]) -- 变化量-1 local current tonumber(redis.call(GET, key) or 0) if current change 0 then redis.call(INCRBY, key, change) return 1 -- 成功 else return 0 -- 库存不足 end通过EVAL命令执行此脚本能确保“判断-扣减”的原子性完美解决超卖。数据库的原子更新UPDATE table SET stock stock - 1 WHERE id 123 AND stock 0这条SQL语句本身是原子的。它避免了“读-改-写”的分步操作将判断和扣减合并为一个原子操作。这是处理类似问题首选且最简单有效的数据库方案。它的延迟取决于数据库本身的行锁竞争但在配合队列合并写请求后竞争已大大减少。4.3 无锁数据结构的设计启发CAS思想催生了无锁Lock-Free甚至无等待Wait-Free的数据结构。例如Java中的AtomicInteger、ConcurrentLinkedQueue都是基于CAS实现的。在设计高性能中间件或内存计算模块时我们可以借鉴这种思想。核心模式在循环中不断尝试CAS操作直到成功。// 伪代码演示无锁更新一个共享配置 public class ConfigHolder { private volatile Config currentConfig; public void updateConfig(Config newConfig) { Config oldConfig; do { oldConfig currentConfig; // 读取当前值 // ... 可能基于oldConfig做一些校验或计算 ... } while (!compareAndSet(oldConfig, newConfig)); // CAS更新 } private synchronized boolean compareAndSet(Config expect, Config update) { if (currentConfig expect) { currentConfig update; return true; } return false; } }这种模式避免了使用synchronized等重量级锁在高并发读、低并发写的场景下性能优势明显。5. 实战设计一个抗高并发的库存系统让我们综合运用以上策略为一个秒杀系统设计库存扣减方案。目标防止超卖保证库存准确性写延迟低于10ms读延迟低于1ms。架构设计库存分层Redis缓存层Cache存放商品可售库存。使用Redis的INCRBYLua脚本实现原子扣减和防超卖。这是扣减的主战场保证高性能和原子性。数据库持久层DB存放商品总库存和最终已售库存。作为数据权威存储和备份。扣减流程写路径用户下单请求到达业务服务不直接访问数据库。业务服务调用“库存服务”请求扣减。库存服务执行Redis Lua脚本原子性地扣减缓存库存。如果脚本返回成功库存充足则 a. 立即返回成功给用户允许其进入支付流程。低延迟达成b. 异步发送一条扣减成功消息{item_id, order_sn, delta}到消息队列如Kafka。如果脚本返回失败库存不足则立即返回“已售罄”给用户。一个独立的“库存同步服务”消费Kafka消息批量将扣减记录写入数据库。可以采用“累加日志”的方式定期与Redis中的缓存库存对账确保最终一致性。查询流程读路径商品详情页查询库存直接读取Redis缓存。延迟在1ms内。后台管理查询总销量等直接查询数据库或基于数据库的OLAP系统。防超卖与一致性保障防超卖由Redis Lua脚本的原子性保证这是核心。缓存与DB一致性通过消息队列异步同步接受秒级延迟。同时库存同步服务可定时如每分钟执行一个校对任务DB_final_stock Redis_cache_stock DB_sold_log。如果发现不一致如Redis宕机恢复后以DB的已售日志为准重建Redis缓存。热点商品隔离对极端热点商品如iPhone首发可以将其库存Key单独放在一个Redis实例上避免影响其他商品。甚至可以采用“库存分段”技术将一个商品的库存拆分成多个Key如stock_item_123_seg_1stock_item_123_seg_2分散扣减压力。这个方案的精髓在于将最核心、最频繁的“判断并扣减”这个RMW操作下沉到Redis中通过一个原子脚本来完成实现了无锁化的极高并发处理。同时通过异步化将持久化操作与快速响应的业务路径分离保证了写请求的低延迟。读请求则完全由缓存承载。6. 不同技术栈的选型与避坑指南不同的编程语言和框架生态在处理高并发写竞争时有不同的最佳实践和坑点。Java利器java.util.concurrent.atomic包下的原子类如AtomicLong、ConcurrentHashMap。适用于单JVM内的共享变量无锁更新。框架Spring框架下结合Transactional和数据库乐观锁Version注解可以便捷实现OCC。但要注意事务边界和失效重试逻辑。避坑不要在高并发下使用synchronized或Lock来保护远程资源如数据库操作这会导致分布式锁问题应用服务器成为瓶颈。应使用数据库自身的原子操作或分布式缓存。Go利器原生支持的sync/atomic包提供AddInt32、CompareAndSwapPointer等原子操作。Channel虽然用于通信但其“同一时刻只有一个goroutine能操作channel”的特性天然适合用来序列化对共享资源的访问可以作为“单消费者队列”模式的高效实现。模式常用“通过Channel通信来共享内存”的理念将需要修改的请求发送给一个专用的goroutine处理该goroutine串行处理避免竞争。避坑虽然sync.Mutex性能很好但在极端性能要求下无锁原子操作仍是首选。使用atomic时要注意内存对齐和ABA问题通常业务场景不涉及。Node.js单线程事件循环这是Node.js的“法宝”也是“软肋”。对于CPU密集的RMW计算会阻塞事件循环。对于I/O操作如数据库更新其异步非阻塞模型能很好处理。策略将高并发的写请求通过一个内存队列如Array配合async/await进行缓冲由一个控制流来批量处理。或者直接将压力转嫁给数据库利用数据库的原子更新或RedisLua脚本。避坑不要在一个高并发HTTP请求回调中直接执行复杂的同步计算或阻塞I/O。对于计数器类需求直接使用Redis的原子命令是最佳选择。数据库层面MySQL善用UPDATE ... WHERE的原子性。对于计数器使用UPDATE counter SET value value 1 WHERE id ?。对于库存使用UPDATE stock SET count count - ? WHERE id ? AND count ?。PostgreSQL功能更强大的SELECT ... FOR UPDATE SKIP LOCKED可以跳过已被锁定的行非常适合实现高效的队列。Redis如前所述INCR/DECR、HINCRBY和Lua脚本是解决分布式并发计数问题的核武器。核心避坑总结切忌在应用层做算术不要在应用内存里计算stock-1然后去更新一定要用数据库或缓存的原子操作。评估冲突率冲突率低如用户维度更新用乐观锁冲突率高如热点商品用队列合并或原子命令。监控与降级必须对CAS失败率、队列长度、缓存命中率进行监控。当CAS失败率过高时要有降级策略例如短暂切换为令牌桶或直接返回售罄。幂等性任何重试机制如乐观锁重试、消息重投都必须保证操作的幂等性即同一请求被处理多次结果一致。高并发下的写竞争处理没有银弹。它是一项结合了架构设计、数据结构、算法和具体中间件的综合工程。理解从硬件CAS到软件乐观锁从同步锁到异步队列的思想脉络能帮助我们在面对具体场景时选择并组合出最适合的武器。记住目标是平衡数据一致性、系统可用性和请求延迟而这一切的起点就是认清“读-改-写”这个简单操作在高并发下究竟有多危险。