1. 为什么今天还要认真聊 Memcache 和 MongoDB?——一个老后端的十年缓存与存储实战手记
我第一次在生产环境里把 Memcache 当成“救命稻草”用,是 2014 年一个电商秒杀系统崩盘的凌晨三点。数据库连接池打满、慢查询告警像鞭炮一样炸响,运维兄弟在电话里喊:“再扛五分钟,DBA 正在切主库!”——而我手抖着敲下
flush_all
命令前,顺手把商品库存、SKU 详情、用户购物车这些高频读、低频写的结构化数据,一股脑塞进了刚搭好的三节点 Memcache 集群。五分钟后,QPS 从 800 直线拉升到 12000,首页加载时间从 3.2 秒压到了 180 毫秒。那一刻我才真正懂了:
缓存不是锦上添花的装饰,而是高并发系统里那根悬在生死线上的保险丝;而 MongoDB 也不是关系型数据库的廉价替代品,它是为“快速迭代、弹性伸缩、容忍模糊一致性”而生的现代数据底座。
这两个工具,一个轻如鸿毛(Memcache),一个重若磐石(MongoDB),它们之间没有谁取代谁的关系,只有如何让“轻”去托住“重”的呼吸节奏。如果你正面临日活百万级的业务增长、频繁的 Schema 变更、或者被 MySQL 主从延迟和分库分表方案折磨得夜不能寐,那么这篇内容就是为你写的——它不讲教科书定义,只讲我在电商、SaaS、IoT 三个领域踩过的坑、算过的账、调过的参。关键词就藏在这句话里:
分布式缓存、文档数据库、读写分离、缓存穿透、冷热数据分层、最终一致性
。无论你是刚写完第一个
SELECT * FROM users
的新人,还是正在设计千万级用户画像系统的架构师,这里的经验都经得起线上流量的反复锤炼。
2. Memcache:不是万能胶,但绝对是系统减压阀——原理、边界与真实世界约束
2.1 它为什么快?快在哪?快得有没有代价?
Memcache 的“快”,绝非玄学,而是由三层硬核设计共同铸就的确定性优势。第一层是
内存直读直写
:所有数据以二进制块形式驻留于物理内存,跳过了文件系统、页缓存、磁盘IO等一切中间环节。一个
get key
操作,本质就是一次哈希表 O(1) 查找 + 内存地址解引用,耗时稳定在 100 微秒以内。第二层是
无状态极简协议
:Memcache 协议本身只有
set
,
get
,
delete
,
incr
等不到十个核心命令,没有事务、没有锁、没有复杂查询解析器。客户端发来一个
get user:1001:profile
,服务端不做任何校验,直接查哈希桶,有就回包,没有就回
END
。第三层是
预分配 slab 内存池
:Memcache 启动时就把整块内存按固定大小(如 96B、120B、160B…)切成无数个 slab class,每个 class 管理同尺寸的 item。这样避免了 malloc/free 带来的内存碎片和锁竞争——写入时直接从对应 size 的 slab 中取一个空闲 slot,删除时只是标记为可用,回收由后台线程统一处理。这三者叠加,让它在单机场景下轻松达到 5~10 万 QPS,远超任何带持久化或事务语义的存储。
但这份“快”是有明确边界的。最致命的限制来自 1MB 默认 value 上限 。这个数字不是拍脑袋定的,而是权衡了内存利用率与网络传输效率的结果。试想:一个 500KB 的用户完整档案 JSON,序列化后塞进 Memcache,反序列化时 CPU 要额外消耗 2~3ms;如果同时有 100 个请求并发反序列化,CPU 就成了瓶颈。更隐蔽的陷阱是 内存碎片放大效应 。假设你存了 1000 个 950KB 的大 Value,Memcache 会把它们全塞进 1MB 的 slab class,实际浪费了 50MB 内存(1000×50KB)。而当这些大 Value 被淘汰后,留下的是一堆无法被小 Value(如 1KB 的 session ID)复用的“大洞”。我曾在线上见过一个案例:业务方误将 2MB 的图片 Base64 字符串存入 Memcache,导致整个实例内存使用率长期卡在 98%,但有效数据仅占 30%,其余全是碎片。最终解决方案不是扩容,而是强制改用对象存储 + URL 缓存。
提示:Memcache 的本质是“面向输出的缓存”,不是“面向计算的数据湖”。它的最佳实践场景永远是: 数据生成后,几乎不修改,且消费方拿到就能直接渲染或返回 。比如:新闻详情页 HTML 片段、API 接口的 JSON 响应体、用户头像 URL、商品 SKU 的价格与库存快照。一旦你开始在代码里对 Memcache 取出的数据做
for循环遍历、map转换、或join关联其他数据,你就已经站在了错误的使用起点上。
2.2 分布式不是魔法,是客户端的一次精准投篮
很多人以为“Memcache 分布式”意味着服务端自动帮你做数据分片和路由,这是个危险的误解。Memcache 服务端本身是完全独立的,没有任何节点间通信机制。所谓的“分布式”,100% 依赖客户端实现。主流客户端(如 libmemcached、spymemcached)采用 Consistent Hashing(一致性哈希) 算法:它把整个哈希空间(0~2^32-1)看作一个首尾相接的环,每个 Memcache 实例根据其 IP+Port 计算出一个或多个虚拟节点位置,落在环上;而每个 Key 也通过哈希计算落到环上,顺时针找到的第一个实例即为该 Key 的归属节点。
这个设计精妙,但也带来两个必须直面的现实问题。第一是
扩容/缩容时的数据迁移成本
。假设你从 3 台机器扩容到 4 台,一致性哈希环上新增了大量虚拟节点,约 25% 的 Key 会重新映射到新节点,导致这部分缓存集体失效。对于高热 Key(如首页 Banner),这可能引发瞬间的数据库雪崩。我们的应对策略是:
扩容前先做“双写”
——新旧集群同时写入,持续 10 分钟;然后切读流量到新集群,观察命中率;最后逐步下线旧集群。第二是
批量操作的天然缺陷
。
get_multi
命令看似高效,但它要求所有 Key 必须落在同一个物理节点上才能一次网络往返完成。如果 100 个 Key 散落在 4 个节点,客户端必须发起 4 次独立请求。因此,业务设计时要主动“聚类”Key:比如用户订单列表页,不要为每个订单生成
order:1001
,
order:1002
… 而是设计成
user_orders:12345
存储一个包含 20 个订单 ID 的数组,再用
get_multi
一次性拉取
order_detail:1001
,
order_detail:1002
… 这样网络 IO 次数从 N 降到 1。
注意:永远不要在 Key 命名中嵌入业务逻辑强相关的动态值(如时间戳、随机数)。我们曾遇到一个悲剧案例:某活动系统用
promo:20240520:token:abc123作为 Key,活动结束后这批 Key 因无过期时间永久滞留,占满内存却永不被访问。正确做法是promo:token:{md5(abc123)}+ 设置合理 TTL,并建立 Key 清理脚本定期扫描过期活动前缀。
2.3 过期机制:不是“定时炸弹”,而是“智能回收员”
Memcache 的过期(TTL)常被误解为“30 天后自动删除”,其实它是一个两阶段的柔性回收机制。第一阶段是
惰性删除(Lazy Expiration)
:每次
get
操作时,服务端才检查该 Key 是否过期,过期则立即丢弃并返回
NOT_FOUND
。这意味着,一个设置了 30 天 TTL 的 Key,如果从未被访问,它会一直躺在内存里,直到内存不足触发第二阶段。第二阶段是
LRU 驱逐(Least Recently Used)
:当内存使用率达到阈值(默认 95%),Memcache 会启动后台线程,扫描最近最少使用的 slab,优先驱逐那些已过期或最久未访问的 item。
这个机制带来了关键启示:
TTL 不是保证数据新鲜度的银弹,而是控制内存水位的安全阀
。如果你的业务要求“绝对实时”,比如银行账户余额,Memcache 永远不该是唯一数据源,它只能是数据库的镜像,且必须配合 Cache-Aside 模式(先查缓存,未命中查 DB 并回填)。我们曾在一个支付对账系统中吃过亏:对账任务每 5 分钟跑一次,依赖
last_reconcile_time
这个 Key 判断是否执行。由于该 Key TTL 设为 0(永不过期),而业务方忘记在每次对账成功后更新它,导致系统永远认为“上次对账没完成”,无限重试。最终修复方案是:所有用于流程控制的 Key,TTL 必须设为略大于任务周期(如 6 分钟),并强制在任务开始时
set
,成功后
delete
,失败则
set
新 TTL。一句话总结:
Memcache 的过期,是用来防内存溢出的,不是用来保数据一致性的。
3. MongoDB:放弃“完美”,拥抱“足够好”的现代数据哲学
3.1 它到底“非关系”在哪?文档模型如何重塑开发体验
MongoDB 的核心突破,在于用 BSON(Binary JSON)文档 替代了传统数据库的二维表。一个用户集合(Collection)不再需要预先定义 schema,你可以插入这样的文档:
{
"_id": ObjectId("507f1f77bcf86cd799439011"),
"name": "张三",
"email": "zhangsan@example.com",
"orders": [
{
"order_id": "ORD-001",
"items": ["iPhone 15", "AirPods"],
"total": 12999
}
],
"preferences": {
"theme": "dark",
"language": "zh-CN"
}
}
而另一个用户文档可以完全不同:
{
"_id": ObjectId("507f1f77bcf86cd799439012"),
"name": "李四",
"phone": "+86 138****1234",
"loyalty_points": 8500,
"last_login": ISODate("2024-05-15T08:30:00Z")
}
这种灵活性带来的直接好处是
开发效率的指数级提升
。在 SaaS 系统中,不同客户对 CRM 字段的需求千差万别:A 客户要记录“首次接触渠道”,B 客户要追踪“决策链图谱”。如果用 MySQL,每次新增字段都要
ALTER TABLE
,在千万级表上执行可能锁表 10 分钟。而 MongoDB 中,只需在应用代码里给文档加一个
channel_source
或
decision_map
字段,立刻生效。我们为一家教育平台做定制化开发时,客户在两周内提出了 17 次字段增删需求,用 MongoDB 实现零停机,而同期另一个用 PostgreSQL 的模块,DBA 已经拒绝了第 5 次
ALTER
请求。
但自由的背面是责任。文档模型最大的陷阱是
过度嵌套导致的更新僵化
。比如上面
orders
数组,如果要更新
ORD-001
的
total
,可以用
$
位置操作符:
db.users.updateOne({"orders.order_id": "ORD-001"}, {"$set": {"orders.$.total": 13999}})
。但如果订单量超过 100,数组过大,这个操作会变慢,且无法利用索引加速。更糟的是,如果要统计“所有用户近 30 天订单总额”,就必须用
$unwind
展开数组,再
$group
聚合,性能堪忧。我们的经验是:
一对少量(<10)、读多写少的关联数据,用嵌入;一对多量大、需独立查询或聚合的,必须拆分为独立集合,用引用(Reference)
。例如,把
orders
拆成
orders
集合,用户文档里只存
["ORD-001", "ORD-002"]
的 ID 数组,查询时用
$lookup
关联——这牺牲了一点读性能,但换来了极致的可维护性和扩展性。
3.2 复制集(Replica Set):五分钟搭建的高可用基石
MongoDB 的复制集是其区别于 Memcache 的根本所在:它提供了 数据持久化、故障自动转移、读写分离 三位一体的能力。一个最小可行的复制集只需 3 个节点(1 主 2 从),部署过程确实如原文所说,5 分钟内可完成。核心配置就三行:
# mongod.conf
replication:
replSetName: "rs0"
oplogSizeMB: 1024
启动三个 mongod 实例后,连上任意一个,执行初始化命令:
rs.initiate({
_id: "rs0",
members: [
{ _id: 0, host: "node1:27017" },
{ _id: 1, host: "node2:27017" },
{ _id: 2, host: "node3:27017" }
]
});
此时,系统自动选举出 Primary 节点处理所有写请求,Secondary 节点通过 Oplog(一种特殊的 capped collection)实时拉取并重放 Primary 的操作日志,实现数据同步。当 Primary 宕机,剩余节点在 10~30 秒内自动发起选举,产生新 Primary。这个过程对应用透明,客户端驱动会自动感知拓扑变化并重连。
但“自动”不等于“无感”。我们必须清醒认识两个关键事实。第一,
Secondary 的数据永远滞后于 Primary
。Oplog 是异步复制的,网络延迟、Secondary 负载高都会导致复制延迟(
rs.printSlaveReplicationInfo()
可查看)。在金融类强一致性场景,绝不能从 Secondary 读取“账户余额”这类关键数据。第二,
读写分离需要显式配置
。默认情况下,所有读请求都发往 Primary。要启用读写分离,必须在连接字符串中指定
readPreference=secondaryPreferred
,并在应用代码中为非关键查询(如“用户历史订单列表”)显式设置读偏好。我们曾在一个 IoT 数据平台踩坑:设备上报数据写入 Primary,而管理后台的“设备状态概览”页面从 Secondary 读取,结果因 2 秒复制延迟,页面显示“设备离线”,而实际设备 2 秒前已上线。最终方案是:所有影响用户操作的读(如“点击下发指令”前的状态检查),强制走 Primary;所有纯展示类读(如“历史曲线图表”),才走 Secondary。
3.3 分片(Sharding):横向扩展的甜蜜与苦涩
当单个 MongoDB 实例的磁盘或 CPU 成为瓶颈,分片是唯一的出路。它将一个集合(Collection)的数据,按某个字段(Shard Key)的值,水平切分成多个 Chunk,分散到不同分片(Shard)上。MongoDB 通过 Mongos 路由进程 统一接收请求,解析 Shard Key,精准转发到目标分片。
选择 Shard Key 是分片成败的咽喉。理想 Key 需满足三个条件:
高基数(Cardinality)、均匀分布、查询高频
。我们曾为一个日志分析系统选错 Key 吃过大亏:初期用
log_level
(只有 DEBUG/INFO/WARN/ERROR 四个值)做 Shard Key,结果所有 ERROR 日志全挤在同一个分片上,该分片 CPU 100%,其他分片闲置。正确做法是用
log_timestamp
(时间戳,高基数)或
device_id
(设备 ID,均匀分布)。但
log_timestamp
有新问题:新日志总写入最新 Chunk,导致“热点分片”(Hot Spot)。最终我们采用复合 Key:
{device_id: 1, log_timestamp: 1}
,既保证了设备数据局部性,又分散了时间写入压力。
分片后,查询性能并非总是线性提升。
范围查询(Range Query)和排序(Sort)在跨分片时代价高昂
。比如
db.logs.find({log_timestamp: {$gte: ISODate("2024-05-01"), $lt: ISODate("2024-05-02")}}).sort({log_level: 1})
,Mongos 必须向所有分片发送查询,收集结果,再在内存中合并排序。当分片数达 10+,这个过程可能耗时数秒。我们的优化铁律是:
所有高频查询,必须能通过 Shard Key 精确路由到单一分片
。为此,我们为日志系统增加了
date_partition
字段(格式
20240501
),查询时强制带上
date_partition: "20240501"
,确保 100% 路由到一个分片。这牺牲了查询的灵活性,但换来了确定性的亚秒级响应。
4. Memcache + MongoDB 黄金搭档:构建高可用、低延迟的数据流水线
4.1 经典 Cache-Aside 模式:不是“先查缓存再查库”,而是“查库后决定是否缓存”
Cache-Aside(旁路缓存)是 Memcache 与 MongoDB 配合的基石模式,但其精髓常被曲解。很多教程说:“读请求先
get
缓存,命中则返回;未命中则
find
MongoDB,再
set
回缓存”。这在简单场景可行,但在真实业务中,它埋下了
缓存击穿、缓存雪崩、数据不一致
三大雷区。
我们以电商商品详情页为例,重构标准流程:
-
请求到达
:用户请求
/product/1001 -
缓存探针(Probe)
:
get product:1001:detail。注意,这不是“查”,而是“探”——我们不关心返回什么,只关心是否HIT。 -
双检加锁(Double-Checked Locking)
:若
MISS,则尝试获取一个分布式锁lock:product:1001:detail(用 Redis 或 MongoDB 的findAndModify实现)。只有抢到锁的请求,才继续下一步;其他请求等待锁释放后,再次get缓存(此时大概率已填充)。 -
源头加载(Source of Truth)
:持有锁的请求,执行
db.products.findOne({_id: ObjectId("1001")}),获取原始数据。 -
智能组装与缓存(Assemble & Cache)
:将 MongoDB 返回的原始文档,结合其他服务(如库存服务、评价服务)的数据,组装成前端所需的完整 JSON。
关键一步
:计算这个组装结果的“业务 TTL”。不是简单设 30 分钟,而是根据数据敏感度动态设定——商品基础信息(名称、描述)TTL=3600s,库存数量 TTL=60s,促销价格 TTL=300s。然后
set product:1001:detail,并附带这个精确 TTL。 - 释放与返回 :释放锁,返回组装后的 JSON。
这个流程解决了所有经典问题:
缓存击穿
(锁保证单一请求回源)、
缓存雪崩
(随机 TTL + 锁排队)、
数据不一致
(所有写操作后,强制
delete product:1001:detail
,下次读自动重建)。我们曾在一个秒杀系统中,将库存 TTL 从 300s 降至 10s,配合锁机制,成功将数据库峰值 QPS 从 12000 压至 800,而用户看到的库存数字误差始终控制在 1 秒内。
4.2 冷热数据分层:用 Memcache 托起 MongoDB 的“热区”
MongoDB 的 WiredTiger 存储引擎虽有 LRU 缓存,但其粒度是 Page(4KB),对高频访问的单个文档(如用户 Profile)效率不高。Memcache 则是文档级缓存。二者结合,形成完美的“内存-磁盘”双层缓存。
我们的分层策略基于 访问频率与数据价值 的量化评估:
-
热数据(Hot)
:QPS > 100,TTL < 5 分钟,数据变更频繁(如购物车、会话)。
全部交由 Memcache 承载
,MongoDB 仅作为持久化备份。写操作:
set cart:user:12345+updateOneMongoDB;读操作:get cart:user:12345,99% 流量不触碰 DB。 -
温数据(Warm)
:QPS 10~100,TTL 5~60 分钟,数据相对稳定(如商品详情、文章内容)。
Memcache 为主,MongoDB 为备
。读操作优先
get,未命中则findMongoDB 并回填;写操作deleteMemcache Key,让下次读自动重建。 - 冷数据(Cold) :QPS < 10,TTL > 1 小时,数据极少变更(如用户注册信息、历史订单)。 MongoDB 为主,Memcache 为辅 。只对极少数超高频冷数据(如首页 Banner)做 Memcache 缓存,其余直接读 MongoDB。
这个策略的关键在于
自动化监控与动态升降级
。我们开发了一个轻量级 Agent,持续采集每个 Key 的
hit_rate
、
avg_get_time
、
set_count
,并计算
value_size
。当发现
product:1001:detail
的
hit_rate
连续 5 分钟低于 80%,且
avg_get_time
> 5ms,则自动将其降级为“温数据”,延长 TTL;反之,若
cart:user:12345
的
set_count
突增 300%,则升级为“热数据”,增加 Memcache 内存配额。这套机制让我们的缓存集群资源利用率常年保持在 85%~92%,避免了人工调优的滞后性。
4.3 缓存一致性:不追求“实时”,而保障“最终”
在分布式系统中,强一致性(Strong Consistency)是奢侈品,最终一致性(Eventual Consistency)才是务实之选。Memcache + MongoDB 的组合,天然适合最终一致性模型。
我们的落地实践是 “写时失效(Write-Invalidate) + 异步补偿(Async Compensation)” 。以用户资料更新为例:
-
写时失效
:用户提交新头像,后端执行
db.users.updateOne({_id: ObjectId("12345")}, {$set: {avatar_url: "new.jpg"}})后, 立即执行delete user:12345:profile。这是最轻量、最可靠的一致性保障,比set新值更安全(避免网络超时导致旧值残留)。 -
异步补偿
:为防止
delete命令丢失(网络故障),我们开启 MongoDB 的 Change Stream,监听users集合的所有update事件。一旦捕获到_id: "12345"的更新,立即触发一个消息队列任务,重试delete user:12345:profile,最多重试 3 次。Change Stream 的延迟通常在 100ms 内,远低于 Memcache 的典型 TTL。
对于更复杂的场景,如“订单状态变更”,我们引入
版本号(Version Number)
。用户文档中增加
profile_version: 123
字段,每次更新 Profile,
profile_version
自增。Memcache 中存储的 Key 变为
user:12345:profile:v123
。当订单服务更新用户积分时,它先
find
MongoDB 获取当前
profile_version
,再
updateOne
并
delete user:12345:profile:v122
(旧版本)。这样,即使缓存失效命令延迟到达,旧版本数据也不会被错误地“复活”。
实操心得:永远不要在业务代码里写
if (cache.get(key) == null) { cache.set(key, db.load()); }。这行代码是缓存不一致的万恶之源。正确的姿势是: 所有写操作,必须伴随明确的缓存失效策略;所有读操作,必须接受短暂的不一致,并用监控兜底 。我们线上有一个 Dashboard,实时展示各业务模块的cache_miss_rate和stale_data_ratio(通过对比缓存值与 MongoDB 值的哈希),一旦stale_data_ratio超过 0.1%,立即告警并触发补偿任务。
5. 真实战场复盘:那些年我们填过的坑与验证过的参数
5.1 Memcache 篇:从内存泄漏到连接风暴
坑一:连接数爆炸与 TIME_WAIT 泛滥
现象:某天凌晨,Memcache 集群所有节点的
ESTABLISHED
连接数飙升至 10000+,
TIME_WAIT
状态连接堆积如山,
get
延迟从 0.1ms 涨到 50ms。
根因:PHP-FPM 配置了
pm.max_children = 1000
,每个子进程都维持一个长连接到 Memcache。当流量突增,子进程创建过多,连接数失控。
解法:
-
客户端层面:启用连接池(如 php-memcached 的
setOption(Memcached::OPT_LIBKETAMA_COMPATIBLE, true)+setOption(Memcached::OPT_CONNECT_TIMEOUT, 100)); -
服务端层面:在 Memcache 启动参数中加入
-c 2000严格限制最大连接数; - 架构层面:在应用与 Memcache 之间加一层 Twemproxy(Nutcracker),它作为 TCP 代理,将后端连接收敛,前端连接数可无限扩展。
坑二:Slab 内存碎片率高达 70%
现象:
stats slabs
显示
evicted
(驱逐数)激增,
get_hits
下降,但
bytes
使用率 95%。
根因:业务方大量存入 1.8MB 的 PDF 元数据(超出 1MB 限制,被截断),同时混杂大量 1KB 的 Session ID,导致小 Value 无法复用大 Slab 的碎片。
解法:
-
立即执行
stats items,定位高占用 slab class(如slab 42); -
用
flush_all清空,但这是治标; -
根本方案:在客户端 SDK 中加入
value_size_validator,对 >950KB 的数据强制拒绝,并返回友好错误ERR_VALUE_TOO_LARGE; -
对必须存的大数据,改用
set命令的noreply选项(不等待服务端响应),降低客户端阻塞风险。
坑三:Key 命名冲突引发的“幽灵数据”
现象:A 业务线的
user:123:profile
与 B 业务线的
user:123:settings
,因命名不规范,被同一段清理脚本误删。
解法:
-
强制推行命名空间(Namespace)规范:
{service}:{entity}:{id}:{field},如auth:user:123:profile、cms:user:123:settings; -
在所有
set操作前,自动注入namespace前缀; -
开发
key-scanner工具,定期扫描所有 Key,按前缀分组统计,生成报告,暴露不合规命名。
5.2 MongoDB 篇:从复制延迟到索引失效
坑一:Secondary 复制延迟飙升至 30 分钟
现象:
rs.printSlaveReplicationInfo()
显示
source: node2
的
syncedTo
时间比
now
晚 1800 秒。
根因:Secondary 节点磁盘为普通 SATA 盘,而 Primary 是 NVMe SSD。Oplog 写入速度远超 Secondary 的磁盘吞吐能力。
解法:
-
立即行动:
rs.syncFrom("node1")强制从另一台健康的 Secondary 同步,绕过慢盘; - 长期方案:所有 Secondary 节点必须与 Primary 使用同等级磁盘;
-
预防:在部署脚本中加入
iostat -x 1 30 | grep -E "(r/s|w/s|await)"磁盘基准测试,不达标则告警。
坑二:
find().sort().limit()
查询全表扫描
现象:一个
db.orders.find({status: "paid"}).sort({created_at: -1}).limit(20)
查询,执行计划(
explain("executionStats")
)显示
nReturned: 20
,但
totalDocsExamined: 2000000
。
根因:缺失复合索引。
status
单字段索引无法支持
sort
,MongoDB 只能先用索引找出所有
status="paid"
的文档(200 万条),再在内存中排序取前 20。
解法:
-
创建精准索引:
db.orders.createIndex({status: 1, created_at: -1}); -
验证:
db.orders.explain("executionStats").find({status: "paid"}).sort({created_at: -1}).limit(20),确认totalDocsExamined降至 20; -
我们的索引黄金法则:
所有
find().sort().limit()查询,必须有覆盖filter字段 +sort字段的复合索引,且filter字段在前,sort字段在后 。
坑三:分片键选择失误导致数据倾斜
现象:
sh.status()
显示
shard0000
的 chunk 数为 1200,
shard0001
仅 12,
shard0000
磁盘使用率 98%,其他 shard 30%。
根因:用
user_id
(UUID 字符串)做 Shard Key,但业务方生成的 UUID 前缀高度相似(如
550e8400-...
),导致哈希后大部分落在同一分片。
解法:
-
紧急:
sh.splitAt("orders", {_id: ObjectId("...")})手动拆分热点 chunk; -
永久:改用
hashed分片键:sh.shardCollection("mydb.orders", {"_id": "hashed"}),MongoDB 自动对_id哈希,保证均匀; -
更优:业务层生成
user_id时,用snowflake算法,确保 ID 本身具备时间+机器+序列的随机性,天然适合作为范围分片键。
5.3 配合篇:缓存穿透与雪崩的终极防御
缓存穿透(Cache Penetration)
现象:恶意请求
get product:-1
、
get product:999999999
,这些 Key 在 MongoDB 中根本不存在,导致所有请求穿透到 DB,DB CPU 100%。
解法:
布隆过滤器(Bloom Filter)前置
。在 Memcache 客户端与应用之间,加一层轻量 Bloom Filter(如 Google Guava 的
BloomFilter<String>
),初始化时将所有合法
product_id
加入。
get
前先
mightContain(id)
,若返回
false
,直接返回空,绝不查缓存和 DB。布隆过滤器有误判率(约 1%),但绝无漏判,完美解决穿透问题。
缓存雪崩(Cache Avalanche)
现象:大量 Key 的 TTL 同时到期(如凌晨 2 点所有商品缓存集中失效),瞬间海量请求涌向 MongoDB。
解法:
TTL 随机化 + 永久缓存兜底
。
-
TTL 随机化:
set key value (3600 + random(0, 600)),让过期时间分散在 1 小时窗口; -
永久缓存兜底:对核心 Key(如首页 Banner),设置
TTL=0(永不过期),但配合refresh-ahead机制——在 Key 过期前 5 分钟,后台线程异步findMongoDB 并set新值,确保用户永远看不到“过期”。
最后分享一个小技巧:在所有
set操作的日志中,强制打印value_size和ttl_seconds。我们曾靠这条日志,快速定位到一个隐藏 Bug:某 SDK 在序列化时,将一个空 Map 序列化为{"a":null,"b":null}(120 字节),而非{}(2 字节),导致缓存体积膨胀 60 倍。日志就是你的第一道防线。
208

被折叠的 条评论
为什么被折叠?



