先记住这个答案
Bucket Pattern 按固定的时间窗口把多次写入聚到一个文档。例如每10分钟内每秒产生的温度读数放入一个文档,数组存储每次值,桶首尾时间记在start和end。文档数由秒数变窗口数,索引缩小且写入批量增加。由于数组有序,查询可先定位桶再在桶内筛选,兼顾读写效率。
- 固定窗口合并读数,文档数大幅下降
- 窗口大小决定查询粒度与单文档体积
- 需结合读写模式选窗口,防热点
- 桶内数组必须有可预测上界
机制:把多次写入压缩成一条文档
MongoDB 默认每条写入都产生一个文档和一条索引项,高频传感器每秒写入会迅速放大文档数和索引体积。Bucket Pattern 将一个固定时间窗口内的所有读数装进一个文档的数组字段:文档间只维护窗口的起点、终点与传感器标识,数组内部保存每次的ts和value。写入从每秒一次变为每个窗口一次,配合$push或批量构造文档,使磁盘上的文档数下降几个数量级。
文档数减少的直接收益是集合和索引的元数据缩小,以及读写路径压力降低。但代价是读操作必须定位到桶后再处理数组:若查询范围大于窗口需跨桶,若小于窗口需在桶内过滤。因此窗口的选取实质是让文档数与单次查询读取的冗余数据之间找折中。索引设计通常用sensor_id + start复合索引即可快速锁定目标桶。
从数据结构看,如果按时间顺序将读数追加到数组,那么数组元素将按时间有序,这支持在应用侧或聚合管道中用二分查找或$slice取子范围,而不必每次都做全表扫描。窗口上限可由采集频率和BOSN大小估算,使文档大小可控且索引层级稳定。
{
"sensor_id": 101,
"start": ISODate("2024-05-01T00:00:00Z"),
"end": ISODate("2024-05-01T00:10:00Z"),
"count": 120,
"readings": [
{ "t": ISODate("2024-05-01T00:00:05Z"), "v": 25.2 },
{ "t": ISODate("2024-05-01T00:00:10Z"), "v": 25.1 },
...
]
}JSON片段展示桶文档:sensor_id标识传感器,start与end为窗口边界,readings数组存放该窗口内每次采样的时间t与数值v。此结构直接对应10分钟窗口内120条数据,实际大小远低于16MB。
场景:200个温度传感器每5秒上报
以200个传感器每5秒上报温度、湿度为例,单日上报量为200×12×60×24=345.6万次。朴素做法每次写一个文档,当日文档量即345.6万。引入Bucket Pattern,每传感器每10分钟划分为一个桶,桶窗口从00:00线性推进,数组收集120条记录,当天每传感器144桶,文档总数降为200×144=28800,下降120倍。
设应用提供两种查询:查某传感器在指定10分钟区间(如10:00-10:10)内的逐次曲线,以及查过去1天按10分钟均值趋势。当查询区间与桶边界对齐时,曲线查询只需读1个桶;若查询区间跨越桶边界,则需读取相邻的2个桶并过滤。日趋势若按自然日整点划分,则恰好读144个桶并在内存聚合成均值;若为滑动24小时,则可能读取145个桶。
当因监控面板需要更多上下文时,可用$slice只取数组尾部若干条,或建一个summary子文档预存每5分钟的平均值。这样既保留原始细节,又避免每次遍历完整数组。
边界:窗口选错与写入集中的失效条件
Bucket Pattern 存在两个主要失败形态。第一个是窗口选得太大:若业务要求任意秒级回放,而窗口内包含数万条数据,读一个桶就要扫描整个数组。虽然可用$arrayElemAt只取单点,却仍要传输整个文档,IO放大明显。这时应缩小窗口,或把原始读数改为子文档按子窗口组织,形成桶内二级桶。
第二个问题是写热点:同一传感器的桶文档被高频更新,若窗口内每1秒都写入,该文档单点写入频率仍为1秒/次。MongoDB对单文档更新是加锁的,多个并发写同一桶会造成严重争用。应对方案有两种:一是缩小窗口使写分散到更多桶,二是由采集端先聚合固定条数再一次性写整个窗口,牺牲写延迟换吞吐。同时必须严格估算桶内最大条数,防止极端突发达到16MB边界。
此外,使用$push每次追加会持续增加文档大小,最终触发文档迁移。因此提前以“窗口内最大可能数据量×单条平均尺寸”校验文档不超过2MB左右通常是安全的工程经验,远小于16MB硬限制,为聚合和页面查询预留余量。
容易答错的地方
- 桶越大越减少文档数,所以应把桶开到16MB
- 过大窗口导致单个文档接近16MB边界,且单桶的更新频率等于窗口内全部写入次数,锁等待和版本刷新反而上升。当范围查询只取桶内一小部分数据时,仍需读入整个文档,IO不降反升。正确判断是由典型查询范围和写入速率共同推导上界。
- 分桶后不再需要索引
- 桶内的数组字段无法直接跨元素建立高效B-tree索引,仍需对
sensor_id、start等桶级字段建复合索引来定位桶。若没有索引,即使桶少也要扫描全集合才能找到目标,查询性能依然崩溃。分桶只是优化了文档密度,并不替代索引。
面试官还会怎么问?
分桶后如何高效得到某传感器在精确时间点的原始读数?
用sensor_id和start先定位可能包含该时间点的桶,然后在桶内数组用时间戳做二分查找或$filter。因为单桶数组按时间有序,在应用侧用二分查找复杂度为O(log n),聚合管道的$filter也足够。若每秒都有这类点查,建议缩小窗口或为桶内时间戳额外建立子文档并做多键索引。
分桶窗口的大小如何根据基准测试动态调整?
先按业务最常查询的时间跨度设置初始窗口,比如请求5分钟曲线则窗口取5分钟。用真实数据统计单文档大小和桶内平均数据量;若单文档大小接近安全上限(如2MB)或观察到明显写入争用(如锁等待增加),就减小窗口。否则可尝试继续增大窗口以期进一步压文档数,每次调整后用WriteLoad测试验证。
桶内数组持续增长,这不就是无界数组反模式吗?
不同。无界数组反模式是把客户、订单这类可能无限增长的列表塞进同一文档,数组长度不可预测且最终可能突破16MB。Bucket Pattern的数组上界由窗口长度和采集频率计算得出,是固定且可预知的,即使定期窗口滚动也不会无限生长,因此属于有界模式。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。