
做后端开发的兄弟对MongoDB应该都不陌生但真正把固定集合Capped Collection用好的项目其实不多。我最早接触它是在做实时日志采集的时候普通集合一天能写入几百万条记录磁盘涨得吓人而且查询越到后面越卡。后来切换到固定集合才真正体会到什么叫“让数据库自己去裁切数据”——不需要定时任务去删老日志也不会因为删除操作占满磁盘IO集合就那么点大满了自然把最旧的数据挤出去。这篇文章就把固定集合从原理到实战、从优势到坑点完整讲一遍想用MongoDB做日志、事件流、实时消息缓冲的同学这篇文章应该能帮你少走不少弯路。1. 固定集合是怎么一回事先搞清楚它能解决什么痛点1.1 普通集合在持续写入场景下的“脏累差”大多数人在刚开始用MongoDB时用的都是普通集合。普通集合的好处是自由插入、删除、更新随便来数据库不会管你。但如果你把它当日志库或者当事件流水表来用麻烦很快就来了。首先是存储失控。日志数据一旦接入每天的写入量基本是固定的比如每秒几百条一天下来就能有几千万条文档。你不可能无限扩容总是要定期清理老数据。清数据一般有两种做法一种是写一个定时任务每晚删除几天前的数据比如db.logs.deleteMany({ time: { $lt: cutoff } })另一种是用TTL索引让MongoDB自动把过期文档从普通集合里删掉。这两种方案都能用但都有隐患。定时删除在数据量大时每删除一批文档都会产生大量随机IO删除期间主库的写入性能会明显抖动。TTL索引看起来优雅可它的后台删除线程也是要干活的而且TTL索引有60秒的扫描周期数据在到期和实际删除之间会有一个滞后窗口。这些都不是致命问题但如果你追求更干净的方案固定集合就是几乎为这类场景量身定做的存在。1.2 固定集合的三大天生属性固定集合Capped Collection可以理解成一个“环形缓冲区”的MongoDB实现集合在创建时指定一个最大空间size单位字节也可以顺便指定最多多少条文档max。当集合写满了数据库不会报错也不会拒绝新写入而是直接覆盖掉最旧的那批文档腾出空间给新数据。它有三大天生属性是普通集合不具备的自然顺序稳定固定集合里的文档始终严格按照插入顺序排列这对很多需要“最新N条”的业务来说省掉了按时间字段排序甚至重复建索引的开销。插入性能高因为不需要为删除维护额外的B树元数据也没有碎片回收固定集合的写入路径比普通集合更短在高并发写入场景下性能表现更稳。自动淘汰老数据满了就覆盖最老的文档整个过程由MongoDB内部完成对应用完全透明。你不用写deleteMany不用建TTL索引也不会有后台删除任务和你的正常写入抢资源。看到这里你就明白了固定集合最适合的就是那些“数据有时效性、插入频繁、不需要单条删除”的场景。比如访问日志、设备上报的传感器数据、近期的消息列表、排行榜快照等。当然它也有一些限制这正是我后面要说的重点。2. 创建与转换从零到一的生产实践2.1 用 createCollection 创建固定集合固定集合不能用普通的插入操作“顺便创建”必须显式调用createCollection否则MongoDB会帮你创建一个普通集合。// 创建一个大小为 1GB、最多 100000 条的固定集合 db.createCollection(access_logs, { capped: true, size: 1073741824, max: 100000 })这段命令干了三件事把access_logs标记为固定集合规定最大物理空间1GB同时规定最多存10万条文档。注意size的单位是字节max是文档数上限。两者不是互斥关系而是谁先到上限谁触发覆盖淘汰。举个例子如果每条文档平均1KB1GB能存100万条但你设了max: 100000那么当文档数到10万条时新写入就会开始覆盖最旧的文档哪怕磁盘空间还有大量剩余。如果你只想限制空间不关心条数可以把max省略db.createCollection(raw_events, { capped: true, size: 536870912 })创建成功后再插入数据、查询数据和普通集合没有太大区别db.access_logs.insertOne({ ip: 10.0.0.1, path: /login, at: new Date() }) db.access_logs.find().sort({ $natural: -1 }).limit(20)注意最后一行用了$natural排序这是MongoDB提供的自然顺序排序对于固定集合来说会直接按插入顺序倒序返回效率比按_id排序高得多。2.2 把普通集合改造成固定集合convertToCapped如果你已经有一个普通集合里面存了不少数据不想折腾重建可以用convertToCapped命令把它改成固定集合。db.runCommand({ convertToCapped: old_events, size: 1073741824 })这个命令的语义是先创建一个同名的固定集合然后把旧集合里的数据按$natural顺序复制进去复制过程中超出size上限的旧数据会被丢弃最后把旧集合替换掉。所以你可以理解成“一次性的滚动归档”已经很旧的文档会在转换时被直接覆盖掉。需要注意两点。第一转换过程会占用额外的磁盘空间新旧集合在转换期间会同时存在你得确保磁盘扣掉老集合大小之后还能放下新集合。第二转换会阻塞操作在高并发的生产库上执行时要估算好时间窗口最好放在业务低峰期否则用户会感知到写入抖动。2.3 size 和 max 到底怎么配才合适这是我最常被问到的问题。很多人一拍脑袋就设置size: 1024*1024*100结果用了两天发现数据全被覆盖了。先给一个建议公式先估算单条文档的平均大小再乘上你希望保留的文档条数再加20%冗余。期望保留条数50000 单条文档平均大小约 200 Byte 基础大小 50000 × 200 10000000 Byte 加上文档头、索引占位等冗余 → size 建议设为 16000000 Byte约 16MB但很多人会忽略max的影响。如果max设置过小即使空间还很充裕也会因为文档条数达到上限而触发覆盖。反过来如果单条文档的大小波动很大比如日志中偶尔有一条几个MB的大报文那么size会先触顶max反而起不到“按照条数保留”的作用。我的实操经验是如果数据是日志优先用size控制总空间max设成size上限按平均大小估算值的2倍左右避免因为大小波动导致条数太少如果数据是业务事件比如订单流水、消息记录更看重“保留最近N条”那就以max为准size设为max × 平均大小 × 1.5。这样无论哪种先触顶都不会出现大的偏差。还有一点容易被忽略MongoDB在固定集合上会为每个文档预留一个记录头通常32字节左右这个不算在size里。所以真正的可用数据空间会比你设置的size稍微小一点。对大规模场景建议在估算基础上多留20%的余量。3. 循环覆盖背后发生了什么内部机制与限制3.1 环形队列与自然顺序固定集合的底层物理结构可以看成环形队列。在WiredTiger存储引擎下固定集合的插入在逻辑上会维护一个“写位置”当写位置到达集合末尾时会重新绕回开头。新文档覆盖旧文档时MongoDB不只是逻辑上标记删除而是直接复用已经释放的存储空间这就避免了普通集合频繁插入、删除之后出现的存储碎片。这也解释了为什么固定集合的插入顺序是稳定的因为在没有并发乱序写入的干扰下物理位置和插入顺序是严格对应的。查询时如果使用$natural实际上就是按物理磁盘顺序扫描性能非常稳定。但要注意环形队列特性也带来了一个新问题你不能依赖固定集合做一个“永不丢失”的数据缓冲。如果写入速率超过消费速率老数据会默默被覆盖你根本感知不到丢了哪些。所以固定集合适合“丢一部分也无所谓”的日志、监控数据不适合需要精确重放的金融流水。3.2 更新和删除的边界固定集合的文档能不能更新能但有硬性限制更新后的文档大小不能超过原始文档大小。如果更新会改变文档大小哪怕只是把一个字符串变长几个字符MongoDB都会返回错误比如After applying the update, the document is larger than the maximum allowed size这类报错。这个限制源于固定集合的物理结构。环形队列里每个文档占用的空间是固定的更新只允许在原有空间内修改。很多新手在固定集合里存了一些带status字段的文档后来想把status从ok改成success发现写不进去就是踩了这个坑。解决办法是把可能变更的字段设计成允许用固定空间比如把status定义成枚举数字或者干脆固定一个较短的字符串长度。删除就更严格了你不能从固定集合中单独删除某条文档。deleteMany({...})、deleteOne({...})在固定集合上都会直接报错。如果你非要清空整个集合只能drop掉整个集合再重建。这个设计看似反人类其实是保证了环形队列的连续性也让固定集合的删除路径完全走“覆盖式淘汰”不会留下空洞。3.3 索引与分片的取舍固定集合默认包含一个_id唯一索引但其他索引的创建是允许的。你完全可以在timestamp字段上建普通索引来加速时间范围查询也可以在device_id上建索引来查某台设备的最新记录。不过索引越多写入成本越高。固定集合本身就是用来扛高并发的建太多索引会让插入性能打折扣。我的建议是能不加索引就不加必须加的字段控制在1~2个并且优先覆盖查询频率最高的场景。比如做日志查询通常按设备ID和时间查就可以建db.access_logs.createIndex({ device_id: 1, timestamp: -1 })这样一个复合索引就能覆盖大多数查询。分片这块有个很硬性的限制固定集合不能分片。MongoDB官方文档明确表示capped collection不支持shardCollection操作。所以如果你有单集合容量超过几TB、需要水平扩展的预期固定集合就不适合你。这种情况建议换普通集合TTL索引或者直接用MongoDB 5.0之后引入的时间序列集合Time Series Collections。4. tailable cursor让固定集合变成简易消息通道4.1 从 tail -f 说起tailable cursor怎么用很多场景下我们需要的不仅仅是“数据能存下来”还需要能实时感知新数据的到来。固定集合配合tailable cursor就能实现类似tail -f的效果——光标一直盯着集合的末尾一旦有新文档插入立刻拿到这条新数据不需要你反复轮询。在MongoDB Shell里没法直接用tailable cursor需要在驱动里设置。下面是一个Node.js驱动的示例const { MongoClient } require(mongodb) async function main() { const client await MongoClient.connect(mongodb://localhost:27017) const db client.db(test) const coll db.collection(event_logs) // 先创建固定集合 await db.createCollection(event_logs, { capped: true, size: 104857600, max: 500000 }) const cursor coll.find({}, { tailable: true, awaitData: true }) cursor.on(data, doc { console.log(New event:, doc) }) cursor.on(error, err { console.log(Cursor invalidated or error:, err) // 需要重新建立游标 }) } main()tailable: true让游标在读取到当前集合末尾后不自动关闭而是等待新数据awaitData: true则告诉服务器如果没有新数据可以暂时挂着等待一小段时间而不是立刻返回空结果。这比你自己定时间间隔轮询find().sort({$natural:-1})要优雅得多也省掉了大部分无效查询。4.2 生产级队列先泼三盆冷水有很多人会拿固定集合当轻量级消息队列用因为它有顺序、有淘汰、还能阻塞监听。我不是说完全不行但你要能接受它的三个固有缺陷没有消费确认机制。消费者读走一条消息之后这条消息不会被标记为已消费仍然留在集合里。如果你需要“至少一次”或“仅一次”的投递保证固定集合做不到你必须自己在应用层做去重和确认。重复消费规避困难。tailable cursor的重启位置不容易精确控制如果你的应用崩溃后重建游标默认会从集合当前末尾开始读中间新到的数据可能被跳过或者你会从某个旧的记录开始重新读一遍。覆盖会静默丢消息。如果消费者消费速度跟不上生产者固定集合的覆盖机制会直接把还没被读到的消息淘汰掉而消费者对此一无所知。这比RabbitMQ或Kafka明确触发“消息过期”更隐蔽排查问题也更困难。所以我的结论是固定集合适合做“事件广播”或“近实时通知”不适合做需要可靠投递的业务消息队列。真正常见的用法是把它和业务解耦——比如把系统产生的操作事件写入固定集合另起一个消费者用tailable cursor读取然后同步到Elasticsearch做全文检索。这个过程当中丢几条不会造成严重问题但即使偶尔丢几条操作主链路也不受影响。4.3 另一类特殊固定集合oplogMongoDB复制集里有个核心组件叫oplog本质上就是一个固定集合通常位于local库名字类似local.oplog.rs。所有写操作都会被记录到oplog里然后从节点拉取oplog并重放以此实现主从同步。如果你理解了固定集合的覆盖机制就明白了为什么oplog要设计成固定集合主节点产生的写操作不断增加不可能无限保存历史通过固定集合自动淘汰最旧的记录从节点只要跟上主节点的进度就不会丢数据。如果从节点宕机太久落后到oplog已经覆盖掉它还没同步到的记录那么从节点就无法继续同步了必须全量重新同步。这给我们一个很有价值的启发在生产上oplog的size大小直接影响从节点能容忍的宕机时长。如果oplog太小从节点多停机几个小时就可能掉出同步窗口。因此很多生产环境会手动调大oplog的大小比如设置为磁盘空间的10%甚至更多。MongoDB允许你对oplog进行扩容虽然操作步骤比较繁琐但原理上就是增加这个固定集合的size。这个例子也提醒我们固定集合不仅仅是用来做日志的它本身也是MongoDB复制机制的地基。5. 避坑指南与调优思路5.1 三个典型坑数据被覆盖、游标失效、convert 的锁第一个坑是“数据莫名其妙消失”。这通常是size和max配置不合理导致的。有两种典型情况一是size太小比如只有64MB结果半天就把旧数据覆盖完了查近一周数据时发现只剩半天的二是max设得太小导致文档条数先触顶明明磁盘空间还有很多但数据一直只保留很少。解决方法是重新估算平均文档大小并考虑用stats查看集合当前使用情况。第二个坑是tailable cursor的失效。tailable cursor不是建好就能一直稳定运行它在以下情况会失效集合被drop或重建、节点运维切换导致游标找不到了、游标空闲时间太长被服务端清理。代码里一定要处理游标关闭事件并定期重建游标。业界有个常用模式一旦游标报CursorNotFound就用一个循环重新执行find({}, { tailable: true })。每次重建游标的起点默认是当前集合末尾所以你需要明确自己想要从头读还是从最新位置读。第三个坑是convertToCapped带来的全局锁阻塞。MongoDB老版本里convertToCapped的锁粒度很大执行期间对同一个数据库的读写都会受影响。即使新版本优化了你的大集合转换依然可能花几分钟期间用户写入会变慢。我的建议是新项目从第一天就创建固定集合而不是先用普通集合跑再转换如果必须转换选业务低谷期执行并提前在测试环境验证耗时。5.2 用 stats 监控固定集合的健康度固定集合虽然会自动淘汰但你仍然需要监控它的运行状态避免出现配置偏差。最直接的方式是用db.access_logs.stats()查看输出里的几个关键字段{ capped: true, max: 100000, size: 1048576, storageSize: 1049088, count: 98765, maxSize: 1073741824, // ... }重点关注count和max的比值。如果count长期贴着max说明集合已经持续处于“满”的状态。如果count远小于max但storageSize接近maxSize说明单条文档很大你主要受空间约束。另外在复制集环境中我曾经通过对比主节点和从节点上同一个固定集合的count发现从节点落后太多导致oplog覆盖直接影响到了同步进度。固定集合本身不是万能的它要发挥最大作用前期配置和后期监控缺一不可。5.3 什么时候别用固定集合固定集合优点明显但“不该用”的场景我会直说。需要频繁单条更新或删除比如订单表、用户资料表千万别用固定集合。数据不能丢比如支付流水、消息主库固定集合的覆盖机制会无差别丢旧数据。需要分片扩展单集合数据规模会超过机器磁盘容量固定集合不支持分片会被卡死。需要准确的时间范围淘汰如果你希望“保留30天数据按具体时间边界清除”固定集合做不到它只按写入顺序覆盖没有时间精度。这种情况下用普通集合加TTL索引会更合适。如果只是想要“近期的数据列表”固定集合和普通集合TTL索引都能做区别在于固定集合省掉了后台删除任务但更新和删除受限。我个人的习惯是数据量大且只增不改、对删除单条无要求的优先考虑固定集合需要灵活变更和精确过期策略的老老实实用普通集合。最后再分享一个小技巧固定集合可以搭配一个“临时滑窗”实现类似缓存的效果。比如你做一个实时监控面板要展示最新的500条告警直接在固定集合上执行find().sort({ $natural: -1 }).limit(500)返回速度比在普通集合上用时间索引排序还要稳定。我实际测过单机写入速率到每秒8000条的时候这个查询依然能稳定在10毫秒内返回。这就是固定集合自然顺序带来的真正红利也是我至今仍喜欢在合适场景下用它、而不是一味追逐新特性的原因。