第27章:Mongodb连接池与高并发写入——秒杀库存如何抗住

发布时间:2026/7/22 12:40:50
第27章:Mongodb连接池与高并发写入——秒杀库存如何抗住 1. 项目背景业务场景本地生活电商的双十一大促进入倒计时。运营团队放出 100 件飞天茅台活动页面刚上线不到 1 秒库存归零——但后端日志显示超过 800 次的写入请求成功了。开发查看代码发现致命问题库存扣减逻辑是先 findOne 查库存 → if 0 → updateOne 扣减的三步走。在高并发下100 个人同时读到库存 0然后 100 个人都执行了 updateOne——最终库存变成了 -85。更糟糕的是连接池配置——Spring Boot 默认 maxPoolSize100秒杀期间 5 万 QPS连接池满导致 3000 多个请求排队等 2 分钟后超时。痛点高并发写入场景下连接池和写入策略是相生相克的。连接池太小——请求排队积压上游超时重试加剧雪崩连接池太大——MongoDB 的连接线程数爆增上下文切换吃掉 CPU。写入策略错误——查后写read-then-write导致超卖不用 bulkWrite 导致每秒只能写入几百条而非数万条没有幂等键导致相同请求被重试时创建了多条重复记录。2. 项目设计小胖盯着压测报告目瞪口呆大师秒杀接口模拟 5000 并发连接池满、超时报错、库存还变成了负数这怎么搞大师秒杀的高并发写入有三个要点缺一不可——原子扣减、幂等去重、连接池压测调优。你现在的代码是三步走的查后写read-then-write自然超卖。小胖那怎么写才对大师一步到位——用原子操作符$inc配合 filter 条件。关键代码一行就够了db.products.updateOne({_id:productId,stock:{$gt:0}},{$inc:{stock:-1},$set:{updatedAt:newDate()}})如果modifiedCount 0——扣减成功抢到了modifiedCount 0——库存不足没抢到。这条 update 在 MongoDB 内部是原子执行的——stock 0的检查和stock - 1的更新在同一个原子操作中完成不存在时间窗口。技术映射原子操作符$inc filter 条件替代 read-then-write 是 MongoDB 并发写入的第一原则。这是 MongoDB 的硬拳头——充分利用单文档原子性不给竞态条件留缝隙。小胖那bulkWrite呢秒杀完之后还得插领取记录吧大师对。秒杀结束、用户扣库成功后还需要插入条领取记录。但如果把扣库存和插记录拆成两次独立的操作中间有网络往返延迟。正确写法是用bulkWrite把多个操作打成一个包发到服务器——一次网络往返完成。技术映射bulkWrite是 MongoDB 的高吞吐写入利器。ordered: false乱序模式下批量中的每一步独立执行、相互不影响——单条失败不阻塞整批。小白追问那幂等呢如果网络抖动用户点了两次抢购会生成两条领取记录吗大师这就是唯一索引 幂等键的作用。在领取记录表上建{userId: 1, productId: 1}的唯一索引。不管客户端发多少次请求第二次insertOne会因为唯一冲突被优雅拒绝——不会产生重复记录。技术映射幂等 重试安全的写入。唯一索引是实现幂等的最简单且最可靠的手段。小胖那连接池怎么调默认 100 够不够大师秒杀的连接池调整要结合实际的并发数和响应延迟。粗略公式maxPoolSize (目标 QPS × P95 延迟 ms) / 1000 20% 余量。比如目标 QPS 5000P95 延迟 5ms → 需要 25 个连接加 20% → 约 30 个连接。但你配 500 个连接是没有用的——MongoDB 每个连接是一个线程500 个连接 × 5ms 延迟意味着大量线程在上下文切换上浪费 CPU。技术映射连接池不是越大越好——它受限于 MongoDB 服务端的并发处理能力。过多的连接导致服务端线程暴涨、上下文切换开销吃掉真正的处理时间。大师总结秒杀三把斧——原子操作防超卖、bulkWrite 提高吞吐、幂等唯一键防重放。连接池大小要根据实测调别拍脑袋。建议压测时先从 50 个连接开始逐步加找到吞吐不再增长的饱和点。3. 项目实战3.1 环境准备沿用 Docker MongoDB复制集环境更好能模拟真实并发场景。dockercompose-fmongodb-lab/docker-compose.ymlps# 或启动 3 节点复制集3.2 分步实现步骤一模拟超卖——错误写法 vs 正确写法目标对比 read-then-write 和原子操作在高并发下的行为差异。use local_life// 准备秒杀商品db.seckill_products.drop()db.seckill_products.insertOne({_id:SKU_MOUTAI,name:飞天茅台 53度,totalStock:100,stock:100,price:NumberDecimal(1499.00),status:active,updatedAt:newDate()})// 领取记录建唯一索引防止重复db.seckill_records.drop()db.seckill_records.createIndex({userId:1,productId:1},{unique:true,name:uk_user_product})print(秒杀商品就绪库存 100 瓶)// 错误写法read-then-write超卖演示 functionbadSeckill(userId){constproductdb.seckill_products.findOne({_id:SKU_MOUTAI})if(!product||product.stock0){return{success:false,reason:库存不足}}// ⚠️ 时间窗口从 read 到 write 之间其他请求可能已经扣了库存constresultdb.seckill_products.updateOne({_id:SKU_MOUTAI},{$inc:{stock:-1},$set:{updatedAt:newDate()}})return{success:result.modifiedCount0,userId}}// 正确写法原子操作 functiongoodSeckill(userId){// 步骤一原子扣库存一步到位constdeductResultdb.seckill_products.updateOne({_id:SKU_MOUTAI,stock:{$gt:0}},// filter 中检查库存{$inc:{stock:-1},$set:{updatedAt:newDate()}})if(deductResult.modifiedCount0){return{success:false,reason:库存不足}}// 步骤二幂等插入领取记录try{db.seckill_records.insertOne({userId:userId,productId:SKU_MOUTAI,claimedAt:newDate()})}catch(e){if(e.code11000){// DuplicateKeyreturn{success:true,reason:幂等——重复请求视为成功}}throwe}return{success:true,userId}}步骤二连接池配置与压测目标配置 Driver 连接池参数并观察不同配置的吞吐差异。// Spring Boot 连接池配置application.yml 关键部分 // spring:// data:// mongodb:// uri: mongodb://user:pwdhost:27017/db?authSourceadmin// connection-pool:// max-size: 50 # 目标连接数// min-size: 10 # 保持 10 个热连接避免冷启动// max-wait-time: 2000ms # 等待可用连接的超时——2秒快速失败// max-connection-idle-time: 600s// max-connection-life-time: 1800s// 手动设置连接池参数mongosh 中无法直接演示此处用说明// Driver 端的 MongoClientSettings// MongoClientSettings settings MongoClientSettings.builder()// .applyConnectionString(new ConnectionString(uri))// .applyToConnectionPoolSettings(b - b// .maxSize(50)// .minSize(10)// .maxWaitTime(2000, TimeUnit.MILLISECONDS)// .maxConnectionIdleTime(10, TimeUnit.MINUTES))// .build();// 连接池监控查看当前连接使用情况 use adminconstconnStatsdb.serverStatus().connectionsprint( MongoDB 连接统计 )print(当前连接:,connStats.current)print(可用连接:,connStats.available)// maxIncomingConnections 65536 默认print(活跃连接:,connStats.active)print(总创建数(自启动):,connStats.totalCreated)print(线程客户端:,connStats.threadedConnections||N/A)// 如果 current / available 80%连接即将耗尽步骤三bulkWrite 批量写入优化目标用 bulkWrite 批量插入记录对比逐条插入的性能差异。// 逐条插入慢 functioninsertOneByOne(count){conststartDate.now()for(leti0;icount;i){db.bulk_test.insertOne({userId:Ui,productId:SKU_BULK,claimedAt:newDate()})}constelapsed(Date.now()-start)/1000return{count,elapsed,qps:(count/elapsed).toFixed(0)}}// bulkWrite 批量快 functioninsertBulkWrite(count,batchSize500){conststartDate.now()lettotalInserted0for(letround0;roundcount;roundbatchSize){constbatch[]constactualSizeMath.min(batchSize,count-round)for(leti0;iactualSize;i){batch.push({insertOne:{document:{userId:BULK_U(roundi),productId:SKU_BULK,claimedAt:newDate()}}})}constresultdb.bulk_test.bulkWrite(batch,{ordered:false})totalInsertedresult.insertedCount}constelapsed(Date.now()-start)/1000return{count:totalInserted,elapsed,qps:(totalInserted/elapsed).toFixed(0)}}// 对比测试db.bulk_test.drop()constsingleResultinsertOneByOne(1000)print(逐条插入 1000条:,singleResult.qps,条/秒)db.bulk_test.drop()constbulkResultinsertBulkWrite(5000,500)print(bulkWrite 5000条:,bulkResult.qps,条/秒 (批次大小 500))// 期望bulkWrite QPS 是逐条的 5-20 倍步骤四消费者队列削峰 前台快速返回目标演示秒杀架构中前台快速返回 后台写入的削峰模式。// Java 削峰代码示例 // 前台秒杀接口只做原子扣库存快速返回结果PostMapping(/seckill)publicSeckillResultseckill(RequestParamStringuserId,RequestParamStringproductId){// 原子扣库存QueryquerynewQuery(Criteria.where(_id).is(productId).and(stock).gt(0));UpdateupdatenewUpdate().inc(stock,-1);longmodifiedmongoTemplate.updateFirst(query,update,SeckillProduct.class).getModifiedCount();if(modified0){returnSeckillResult.fail(已抢光);}// 丢一个事件到队列异步处理插领取记录、发消息、刷新缓存等eventQueue.offer(newSeckillEvent(userId,productId,Instant.now()));returnSeckillResult.success(抢到啦);}// 后台消费者从队列读取事件处理剩余的写入领券记录、统计等EventListenerpublicvoidhandleSeckillEvent(SeckillEventevent){try{mongoTemplate.insert(newSeckillRecord(event.userId,event.productId,event.time),seckill_records);}catch(DuplicateKeyExceptione){// 幂等——忽略重复}}步骤五压测与监控——如何找到连接池的最佳值目标通过逐步增大连接池找到应用吞吐的饱和点。// 压测监控脚本在 mongosh 中观察// 压测期间在不同窗口运行此脚本functionmonitorDuringLoadTest(intervalMs2000,rounds10){for(leti0;irounds;i){constconndb.serverStatus().connectionsconstopsdb.serverStatus().opcounters// 计算 QPS两次采样的差值/时间差consttotalOpsops.insertops.queryops.updateops.deleteprint([${newDate().toISOString().slice(11,19)}]连接:${conn.current}/${conn.available}活跃:${conn.active}操作累计:${totalOps})sleep(intervalMs)}}monitorDuringLoadTest(2000,15)3.3 完整代码清单文件用途mongodb-lab/scripts/ch27-seckill-setup.js秒杀场景初始化mongodb-lab/scripts/ch27-oversell-demo.js超卖 vs 原子扣减对比mongodb-lab/scripts/ch27-bulkwrite-perf.jsbulkWrite 性能对比mongodb-lab/scripts/ch27-connection-monitor.js连接池监控脚本3.4 测试验证use local_life// 1. 原子扣减验证——模拟并发扣库存db.seckill_products.updateOne({_id:SKU_MOUTAI},{$set:{stock:3}})// 连续发起 10 次扣减请求只有前 3 次成功letsuccessCount0for(leti0;i10;i){constrdb.seckill_products.updateOne({_id:SKU_MOUTAI,stock:{$gt:0}},{$inc:{stock:-1}})if(r.modifiedCount0)successCount}print(尝试 10 次, 成功:,successCount,successCount3?PASS:FAIL)// 2. 幂等验证——相同 userIdproductId 不能重复插入db.seckill_records.deleteMany({})db.seckill_records.insertOne({userId:IDEMPOTENT_1,productId:SKU_MOUTAI,claimedAt:newDate()})try{db.seckill_records.insertOne({userId:IDEMPOTENT_1,productId:SKU_MOUTAI,claimedAt:newDate()})print(幂等验证: FAIL (允许重复插入))}catch(e){print(幂等验证:,e.code11000?PASS (唯一冲突拦截):FAIL)}// 3. 连接池监控constconndb.serverStatus().connectionsprint(连接:,conn.current,| 可用:,conn.available,conn.currentconn.available*0.8?PASS:⚠ 接近上限)print(\n 高并发写入验证完成 )4. 项目总结4.1 秒杀架构决策矩阵场景写入策略连接池幂等削峰普通商品下单原子 $inc filter默认 50订单号唯一索引无需限时秒杀原子 $inc 快速返回调大到 100userIdproductId 唯一索引前台扣库 后台 consumer批量消息发送bulkWrite (ordered:false)调大到 200messageId 唯一索引Kafka/Redis 队列日志采集bulkWrite 单批次 500-1000调大到 100日志 ID 唯一索引或无需批量攒批写入4.2 适用场景本章优化适用秒杀/抢购——原子扣库存 幂等记录 前台快速返回。高并发的优惠券发放——原子库存扣减 兜底唯一冲突。批量数据导入——bulkWrite 替代逐条 insert。日志/事件流灌入——bulkWrite 异步 consumer 削峰。热点写入场景的专项优化——调优连接池、增大 opLog 窗口、开启写关注 majority。4.3 注意事项注意事项说明原子操作仅限于单文档跨文档的原子性必须用事务第 11 章ordered: false的副作用批量中的错误不会阻止其他操作执行——检查每条结果唯一索引和正常索引不可混用幂等键是唯一索引不要在这个字段上再建普通索引连接池的maxWaitTime设为 2s失败快速返回由上游重试比排队等 2 分钟更健康bulkWrite 单批次不可太大单批次 1000 条可能导致单次执行时间过长影响复制延迟4.4 常见踩坑经验故障案例一连接池 maxPoolSize10 打满后服务雪崩某秒杀服务部署了 20 个 Pod每个 Pod 的 maxPoolSize10。秒杀开始时 20×10200 个连接全部被秒杀请求占满。其他正常业务请求如查订单、查物流也需要连接但连接池是独立的——问题在于秒杀服务的连接池满后该 Pod 的健康检查也走 MongoDB 查询——健康检查拿不到连接超时K8s 把 Pod 标记为 Unhealthy 重启重启后瞬间又被打满——反复 CrashLoopBackOff。解决① 秒杀服务单独部署、独立的 MongoDB 连接池② 健康检查不走 MongoDB用简单的 TCP 端口检查③ maxWaitTime 设为 1 秒快速失败。故障案例二read-then-write 的库存超卖在分片集群中更严重某团队用 read-then-write 做分片集群中的库存扣减。分片集群中 read 可能被路由到 Secondary从库write 必须去 Primary——从库读到的库存和主库的最新库存之间存在复制延迟超卖比单机更严重。解决必须用原子$inc filter、writeConcern majority、readPreference primary 三位一体。故障案例三bulkWrite 单条异常导致整批回滚某团队 bulkWrite 设置了ordered: true默认批量 1000 条中的第 500 条因为唯一键冲突失败后 500 条全部没执行——但前 499 条已成功已提交不回滚。应用层以为批量失败了重新发送了这 1000 条——导致前 499 条被重复处理的副作用。解决ordered: false 每个 write 结果检查 幂等键兜底。批量异常不代表全部失败。4.5 思考题在分片集群中做秒杀如果分片键不是productIdupdateOne({_id: productId, stock: {$gt: 0}}, {$inc: {stock: -1}})能否精确路由到单个分片如果不能会有什么后果如果连接池的maxWaitTime设为 0永远等待在高并发下会发生什么答案将在第 28 章末尾揭晓上一章思考题答案删除文档不会自动减少 Chunk 数——Chunk 划分基于分片键的值范围而非文档数量。即使一个 Chunk 内的文档被清空这个 Chunk 仍然存在成为空 ChunkChunk 数不变。MongoDB 会定期合并小的空 Chunk但这是后台操作而非实时。reshardCollection如果在执行到一半时 mongos 挂了——重启后 reshard 操作不会自动恢复需要手动重新发起。但旧集合的数据完好无损——reshard 在内部创建了一个新的目标集合原子切换发生在最后。如果切换前失败旧集合不受任何影响如果切换后失败新集合已经生效数据完整。可通过sh.status()查看残留的 resharding 操作。延伸阅读与资源MongoDB 实战进阶与内核修炼python入门Rquests从菜鸟脚本到企业级SDK的网络实战圣经Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析