弹幕写入卡在 insertOne 是因高并发下单文档插入触发写锁瓶颈;应改用 bulkWrite 批量写入、内存缓冲、TTL 索引自动过期,并禁用 journal 和 writeConcern 等非必要持久化开销。
insertOne 上?不是 MongoDB 慢,是默认的单文档插入在高并发弹幕场景下直接撞上写锁瓶颈。每条弹幕走一次 insertOne,1000 QPS 就意味着每秒 1000 次磁盘刷写+索引更新,writeConcern: "majority" 下延迟飙升是必然结果。真实压测中,你看到的 WriteConflict 错误或 InterruptedAtShutdown 日志,往往不是服务挂了,而是写入队列在排队等锁。
解决思路不是加机器,而是把“写操作”从「逐条提交」变成「批量缓冲 + 延迟落盘」——也就是所谓“写入悬挂”:让弹幕先进内存队列,攒够一批再统一 insertMany,同时用 TTL 索引自动清理过期数据,避免手动删库。
journal: true(开发/测试环境),生产环境可设为 journal: false 配合副本集多数写入保障持久性writeConcern 的等待确认(如设为 {w: 0}),由应用层兜底重试逻辑content 字段建全文索引——搜索弹幕靠 ES 或向量库,MongoDB 只做可靠暂存bulkWrite + 内存队列实现悬挂写入别手写线程池或用 Redis 做中间队列,太重。Node.js 场景下,直接用 stream.Readable 搭配 bulkWrite 更轻量:
const buffer = [];setInterval(async () => { if (buffer.length === 0) return; try { await db.collection('danmaku').bulkWrite( buffer.map(msg => ({ insertOne: { document: { ...msg, ts: new Date(), _id: new ObjectId() } } })), { ordered: false } // 允许部分失败,不中断整个批次 ); buffer.length = 0; // 清空 } catch (e) { console.error('bulkWrite failed:', e); // 失败时保留 buffer,下次重试(注意防重复) }}, 100); // 每100ms flush 一次
关键点:
ordered: false 是必须的——某条弹幕字段非法(比如 content 超长)不能阻塞整批写入if (buffer.length > 500) buffer.shift()),防止 OOMmsg 必须带唯一 _id,否则 bulkWrite 会自动生成,导致无法去重查弹幕不是查历史,是查“最近 60 秒活跃弹幕”,所以不能依赖 _id 排序——ObjectId 时间戳精度只有秒级,且写入时间 ≠ 显示时间。正确做法是:
ts 字段,并建复合索引:db.danmaku.createIndex({ roomId: 1, ts: -1 })
find({ roomId: "123", ts: { $gt: new Date(Date.now() - 60 * 1000) } }).limit(200)
db.danmaku.createIndex({ ts: 1 }, { expireAfterSeconds: 3600 }),1 小时后自动删,比定时任务更稳注意:expireAfterSeconds 仅对单字段生效,不能用在 { roomId: 1, ts: 1 } 复合索引上;TTL 删除是后台线程异步执行,不保证精确到秒,但对弹幕这种弱时效数据完全够用。
changeStream 实时推送?很多人想用 changeStream 把新弹幕推给客户端,实际会踩两个坑:
OplogTruncation 导致流中断更合理的分层是:写入走悬挂 bulkWrite → 内存缓存最近 200 条 → 新连接直接拉缓存 + 订阅 Redis Pub/Sub 做增量同步。MongoDB 在这里只做最终一致的持久化底座,不参与实时链路。
真正容易被忽略的是 buffer 的生命周期管理——它既不能跨进程共享(Cluster 模式下每个 worker 都要独立 buffer),也不能依赖 GC 自动回收(V8 不保证及时)。必须用 process.on('SIGTERM', flushAndExit) 做优雅退出,否则进程杀掉时 buffer 里几百条弹幕就丢了。