固定集合这个名字,听起来像是 MongoDB 新手阶段练手才会碰的东西,但我个人觉得它恰恰是很多后端老手也会忽略的宝藏特性。最早我是做访问日志模块时真正领会到它的价值:一天上千万条日志,全量保存不可能,定期清理又会让普通集合的存储碎片越来越难看,而固定集合(capped collection)天然就是为这种场景准备的——集合有容量上限,写满后自动覆盖最早的数据,像一个环形缓冲池,不需要你手动删除任何东西。这篇文章我就把固定集合从原理、创建、容量计算、实战游标到踩坑经验一次性讲透,适合做日志、消息缓存、埋点、最近N条记录这类功能的后端开发、运维和架构师参考。
1. 固定集合是什么,为什么需要它
1.1 环形缓冲区的数据库版本
固定集合,从名字也能猜个大概:这个集合有一个“固定”的容量上限,一旦你创建时设定了size或者max,它就只在这个范围内工作。写入新文档时,如果容量还没满,就正常追加;如果已经满了,就把最老的那批文档直接覆盖掉。这种机制你可以理解为操作系统里的环形缓冲区:指针转一圈,回到起点继续写,旧数据自然被冲掉。
这里有一个关键点:固定集合的写入顺序是确定的。普通集合里,文档的物理存放顺序和逻辑顺序没有必然关系,查询时如果不带sort,你得到的结果顺序不一定等于插入顺序。但固定集合不一样,它维护了清晰的“自然顺序”,也就是文档写入的顺序。默认查询返回的是从旧到新,配合$natural排序可以直接拿到正向或反向的插入顺序。
也正因为如此,固定集合不太适合当成什么“高级存储”来用,它更适合承载那些“有保质期”的数据:日志、事件流、最近访问记录、待消费的任务列表。这个定位从 MongoDB 内部也能看出来——复制功能依赖的 oplog 本身就是固定集合,官方自己都在用这个特性做“只保留最近变更记录”的引擎。
1.2 和普通集合加手动清理相比省在哪里
很多人在没接触固定集合之前,处理“只留最近N条”这件事通常是这么干的:正常往普通集合里写,然后写一个定时任务,定期把老数据删掉。看起来没什么问题,但实际跑一段时间你就会发现几个痛点。
第一,删除是有代价的。普通集合的删除操作走的是正常的增删改查路径,删除的文档越多,对索引的维护压力越大,写并发高的时候删除任务本身还会和业务请求抢资源。第二,空间碎片很难看。删掉一批文档后,集合在磁盘上留出的“洞”未必能被新写入充分利用,集合文件越涨越大,用compact之类的命令又得挑低峰期操作。第三,删除的时机不好把控。删除频率太低,磁盘容易被打满;删除频率太高,又浪费性能。固定集合把这些事情全压在写入路径上解决:满了就在原地覆盖最老的数据,不需要额外的定时任务,也不需要关心碎片的回收。
1.3 和 TTL 索引的差异:别选错方案
MongoDB 里还有一个和固定集合功能很像的机制:TTL 索引。它允许你给某个时间字段建索引,到期后后台线程自动删除过期文档。很多人问:既然有 TTL,还要固定集合干什么?
TTL 的核心是“按时间过期”,它的清理动作由后台线程周期执行,一般每 60 秒跑一次,这就意味着文档的实际存活时间会超出你设定的时间,而且这个偏差在数据量大时可能更明显。TTL 适合的是那些“每条数据有自己的过期时间”的场景,比如验证码、临时 token、七天内的购物车记录。固定集合的核心是“按容量或条数滚动淘汰”,它不管文档本身的时间戳,只关心集合有没有装满。所以你要是想“始终保留最近 100 万条订单流水”或者“日志最多占 5GB 磁盘”,固定集合比武断的 TTL 更合适。两者不冲突,甚至可以组合使用:固定集合里再配一个普通索引支持按时间查询,效果也不错。
2. 创建与容量管理:语法、命令和常见误区
2.1 从空集合开始创建固定集合
固定集合不能用普通方式第一次写入时隐式创建,必须显式调用createCollection,因为你要告诉 MongoDB 它的容量是多少。基本语法是:
db.createCollection("audit_log", { capped: true, size: 5368709120, // 5GB,单位是字节 max: 2000000 // 最多 200 万条,可选 });size是固定集合的总容量字节数,max是最多文档条数。这两个参数不是必须同时出现,但我的建议是size一定要给,max按需给。很多人只写了max而忘记size,结果 MongoDB 在部分版本上会给一个非常小的默认容量,你的集合可能没写几条就开始覆盖了,表现就像“数据莫名其妙丢失”。固定集合的容量一旦定下来,业务写入就受它硬约束,所以这个值得在一开始就认真算清楚。
另外,如果你是在副本集或分片集群里操作,createCollection这条命令可以写在主节点上,它会通过复制同步到从节点。集合创建完之后,最好立刻用db.audit_log.stats()确认一下capped: true已经生效。
2.2 把已有普通集合转换为固定集合
如果项目已经跑了很久,数据都在普通集合里,你不想停机重建,可以用convertToCapped命令做在线转换:
db.runCommand({ convertToCapped: "audit_log", size: 5368709120, max: 2000000 });这个命令会把一个普通集合复制并重建成固定集合。需要注意它不是瞬间完成的,数据量越大,花费的时间和占用的 IO 就越多,尤其是集合里已经攒了几十 GB 数据时,执行期间对业务写入会有明显影响。我的实战经验是:转换操作务必放在低峰期,并且先找一个从节点或者测试环境试跑一遍。如果你只是想调整一个已经存在的固定集合容量,最稳妥的办法不是去赌某个隐蔽的collMod参数,而是新建一个容量更大的固定集合,把数据迁移过去,再让业务流量切换。生产环境里少用花哨命令,多走“新老替换”这种朴素但可靠的流程。
2.3 容量相关的偏移量思维
还有一点容易被忽略:size是“总容量”,但它并不完全等于“文档体积之和”。MongoDB 内部在存储引擎层面会有很多额外开销,比如文档对齐、填充因子、索引空间。WiredTiger 引擎下,固定集合一开始也不会真的把所有size空间都预分配到磁盘上,storageSize会随着写入增长缓慢接近maxSize。这意味着你不能拿业务文档的平均大小直接乘以条数来估算空间,得留出至少 20% 到 50% 的余量。
db.audit_log.stats()你可以从输出里看几个关键字段:capped表示是不是固定集合;maxSize是创建时设定的容量上限;size是当前所有文档身体积的总和;storageSize是磁盘上实际占用的空间;max是文档条数上限。如果storageSize已经非常接近maxSize,说明这个集合已经处在持续覆盖边缘,很快就要开始滚动淘汰旧数据了。
3. 读写规则与使用边界:哪些操作能踩雷
3.1 自然顺序、索引与查询
固定集合最舒服的一点,是不需要额外担心查询顺序。普通集合里做“取最新几条”通常要依赖_id倒序或者时间字段倒序,而固定集合可以直接这样写:
// 从旧到新 db.audit_log.find().sort({ $natural: 1 }); // 从新到旧,业务上更常用 db.audit_log.find().sort({ $natural: -1 });$natural就是物理存储顺序的意思,固定集合里它接近插入顺序。不过要注意,这只是“接近”,不是绝对保证。比如你在更新文档后,文档位置相对移动了,$natural的顺序可能就不再是严格的插入先后。所以不要把固定集合当成强顺序的消息队列来设计,它更适合“最近的数据大概率在末尾”这种弱顺序场景。
索引在固定集合上是可以建的。因为固定集合会不断覆盖老数据,索引的体积也不会无限膨胀,这对写日志这种高频写场景很有帮助。但有一个老毛病要提一下:固定集合对唯一索引的支持一直不让人省心,很多版本直接禁止在 capped collection 上创建唯一索引。我在生产里一般默认不依赖它,业务唯一性靠_id兜底,复杂唯一约束就让给普通集合去承担。
3.2 文档更新、删除和分片限制
固定集合的写路径是为追加设计的,这就带来了一系列边界。最常踩的坑是更新:如果更新导致文档体积变大,在很长一段 MongoDB 版本里会直接报错,错误信息类似cannot change the size of a document in a capped collection。即使新版引擎在某些条件下更包容,你也别把固定集合当成普通业务表来用,尤其是不要push数组、不要往里塞持续增长的大字段。保持文档大小相对稳定,是固定集合健康运行的基本原则。
删除也不一样。固定集合的设计目标就是“老数据被自动覆盖”,所以从早期版本开始,官方对这种集合的单条文档手动删除支持一直很弱。你要清空一个固定集合,最干净的做法通常是直接drop整个集合后重建,而不是写脚本一条条删。我见过不少新人上线前把固定集合当成普通日志表来设计“清理任务”,结果跑出各种灵异问题。如果你预计业务需要频繁单条删除,请重新考虑要不要用普通集合。
分片也是个硬边界:固定集合不能参与分片。想横向扩展写入能力,又惦记着滚动淘汰功能,这种组合在 MongoDB 里基本是不存在的。遇到这种需求,老老实实改成普通集合,外加按时间分区或者自定义滚动表方案。
4. Tailable 游标:把固定集合变成轻量消息队列
4.1 为什么固定集合天生适合做队列
固定集合最吸引人的衍生能力,就是 Tailable 游标,也就是“可尾随游标”。你可以理解成在终端里tail -f一个日志文件:游标会一直盯住集合的末尾,有新数据写入就立刻读出来,没有新数据时它就睡在那里等,而不是直接结束。这个能力配合固定集合“老数据自动覆盖”的特性,非常适合做轻量级消息队列、埋点管道和事件流。
MongoDB 自己的 oplog 就是固定集合,副本集的同步进程本质上是拿着一个 Tailable 游标在追 oplog 的尾部。这也是为什么我一直建议想理解这个特性的朋友,先去看看 oplog 的工作方式,你就会意识到固定集合不是玩具,它支撑着整个 MongoDB 的复制机制。
4.2 用 PyMongo 写一个可跑通的消息消费端
假设你有一个task_queue固定集合,我直接用 PyMongo 写一个最简单的消费者:
import time from pymongo import MongoClient, CursorType client = MongoClient("mongodb://127.0.0.1:27017") db = client["demo"] coll = db["task_queue"] while True: cursor = coll.find( {}, cursor_type=CursorType.TAILABLE_AWAIT, await_data=True, ) while cursor.alive: try: doc = next(cursor) handle(doc) # 你的业务处理逻辑 except StopIteration: # 暂时没有新数据,等一会继续 time.sleep(0.2) time.sleep(1)核心就两点:CursorType.TAILABLE_AWAIT让游标具备“没数据时等待新数据”的能力,await_data告诉服务端在暂时没有数据时不要马上返回空结果,而是稍微等一会儿再有新数据时返回。这样消费端就不需要反复轮询整个集合,也不太容易漏掉写入间隙的数据,对于单机内部的轻量任务分发完全够用。
4.3 Tailable 游标和真正的消息队列差异
不过要清醒一点:这不是 Kafka,也不是 RabbitMQ。固定集合里的消息被消费后并不会被标记为“已消费”,所谓队列语义是靠cursor自己记住读取位置实现的。如果消费者挂了重连,它只能从头开始读还没被覆盖的数据,也就是说有可能重复消费,而一旦容量满,老消息又会被直接丢弃,你做不到“确认消费”和“精确一次”。所以固定集合适合的是那些“丢了拉倒”或者“重复处理无所谓”的场景,比如打点上报、缓存构建、内部任务通知。一旦牵扯到事务性消息、严格 ack、多消费者组并发消费,请直接上专业消息中间件,别拿 MongoDB 硬顶着当一个高可用队列。还有一点,Tailable 游标在现代驱动里的行为有细微差异,生产使用前务必把“游标耗尽后重新拉取”的逻辑处理干净,否则消费者可能静默退出。
5. 容量设计:别凭感觉填 size
5.1 一套可以抄走的估算方法
很多人建固定集合时,size都是拍脑袋给的:先给 1GB,跑几天发现满了加一倍。这么做也不是不行,但更好的方式是提前用公式推一遍。一般流程是这样:
- 先算平均单条文档大小,在 Mongo Shell 里可以用
Object.bsonsize(doc)拿到某条文档的真实字节数; - 再算保留窗口内需要容纳多少条,比如业务是每天 500 万条日志,你想保留 30 分钟,那就是约 10 万条;
- 最后乘以文档大小,再留 30% 到 50% 的存储引擎开销,得到的就是
size建议值。
举个例子:接口每秒产生 500 条日志,平均每条 2KB,要保留 1 小时。计算如下:500 × 3600 × 2048 = 3,686,400,000字节,约 3.44GB。按 1.4 倍补上存储引擎和索引的额外开销,大概 4.8GB,size直接取 5GB 是合理的。如果产品允许丢一点数据,可以适当缩到 3GB,但代价是高峰期可能快速滚动覆盖。
5.2 用 stats 动态监控滚动边界
容量设计完不是万事大吉,还得监控。我自己的做法是给固定集合写一个定时巡检脚本,每隔几小时看一次db.<collection>.stats()里的maxSize和storageSize。当storageSize稳定在maxSize的 95% 以上时,说明这个集合已经进入正常覆盖状态,你的容量上限和业务量是匹配的。如果storageSize长期只有maxSize的一小半,说明容量给大了,可以考虑缩减;反过来如果刚上线几小时就冲到 100%,说明要么容量给小了,要么文档平均大小远超预期,需要立刻排查。
5.3 扩容的正确姿势
固定集合的容量一旦创建,理论上就该稳定,但业务发展总有需要扩容的时候。我见过有朋友尝试直接在原集合上修改配置,结果不同版本的 MongoDB 对这个操作的支持完全不一样,低版本直接不认。最稳的流程是:新建一个容量更大的固定集合,比如audit_log_v2,然后在业务代码里做一次短暂的双写或者切换,再把老集合drop掉。整个切换过程里数据会有少量丢失,所以固定集合的容量规划为什么要在上线前认真做,原因就在这——它天然不保证“历史全量”,你需要的是把丢失窗口控制在可接受范围。
6. 常见问题与排查技巧实录
6.1 问题速查表
我把这些年实际踩过的和同行问过的问题整理成一个速查表,排查时可以对照着看。
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 集合里数据条数远小于预期 | size太小,老数据已经被滚动覆盖 | 检查stats()中storageSize是否接近maxSize,按业务需要扩容 |
| 更新文档报大小错误 | 更新后文档体积增大,触碰固定集合限制 | 保持文档大小稳定,避免$push无界数组;必要时改用普通集合 |
| 单条记录删不掉 | 固定集合对按文档删除支持很弱 | 清空用drop重建,设计上不要依赖单条删除 |
| 创建唯一索引失败 | 固定集合对唯一索引支持有限 | 用_id保证唯一性,复杂唯一约束放普通集合 |
| 想分片但固定集合不配合 | 固定集合不能分片 | 改用普通集合 + 时间分片/滚动表方案 |
| Tailable 游标消费端静默退出 | 游标耗尽后没有重新拉取 | 在循环里处理StopIteration,拉取姿势要重新建立游标 |
convertToCapped执行期间业务卡顿 | 命令会复制全量文档 | 低峰期执行,并把业务流量降级或切换 |
6.2 避坑清单
最后整理几条我从实战里沉淀下来的原则。第一条,固定集合不要存放不可再生的核心业务数据,它本质是一个缓冲池,不是保险箱。第二条,容量规划宁大勿小,因为扩容比创建麻烦得多;但也不要大到没有意义,那会浪费磁盘。第三条,设计时把“数据会被覆盖”写进业务预期,而不是等线上真的丢数据了再惊讶。第四条,能用 Tailable 游标消费就别写高频轮询,轮询会放大集合压力,游标才是匹配固定集合生产力的用法。第五条,所有固定集合相关的索引、更新、删除行为,都要在目标 MongoDB 版本上先用测试数据跑一遍,因为版本之间的行为差异真的存在。
我自己现在的习惯是:固定集合上线前,先灌一倍容量预估量的测试数据进去,观察滚动覆盖的临界行为、storageSize增长曲线,以及 Tailable 游标在“空转—有新数据—覆盖老数据”几种状态下的表现。这一套动作花不了多长时间,但能帮你躲开很多“数据看起来丢了几条”的线上事故。固定集合没多复杂,搞清楚它的能力边界,它就是 MongoDB 里最顺手的日志和队列小工具。