☰
MongoDB热点数据缓存击穿:从识别到两级缓存优化实战
2026/10/5 7:13:42 网站建设 项目流程

上周五晚上十点,运营那边报了个紧急问题:积分排行榜接口的RT从60ms一路涨到3s,MongoDB主节点的CPU直接冲到99%,慢查询日志刷了满满一页。我翻了下日志,发现其中一条记录特别刺眼——同一个文档ID在十分钟内被查了48万次。这不是业务异常,而是典型的热点数据把缓存击穿了,请求全部穿透到了数据库。

查了一下,这台MongoDB的版本是4.4,WiredTiger存储引擎,配置不算低,但面对这种集中式的热点流量,单节点的处理能力很快就见了底。事后复盘,真正的问题不在MongoDB本身,而在于我们对热点数据完全没有感知,缓存策略也停留在“全量缓存+固定过期时间”的粗放阶段。

这篇文章就把整个修复过程梳理一遍。核心围绕三个词展开:热点数据识别、缓存策略、访问速度优化。你会看到我怎么统计热点文档、怎么设计多级缓存、怎么处理缓存和MongoDB之间的数据一致性,以及那些踩过之后才知道的坑。适合正在用MongoDB做核心存储、但还没有一套成熟缓存体系的朋友参考。

1. 整体设计思路:先能感知热点,缓存才有意义

1.1 热点数据导致的性能问题远比你想象的严重

很多人对MongoDB热点数据的认知还停留在“某个Key访问特别多”。但实际生产环境中,热点数据造成的危害是一个完整的链条,而且每一环都可能让你睡不着觉。

首先是WiredTiger的缓存池问题。MongoDB主要靠WiredTiger的Page Cache加速读操作,当大量请求集中在同一批文档上,这些文档对应的Page会被频繁加载和驱逐。你可能会想,缓存命中率高不是好事吗?问题在于,如果文档数据本身就大于内存缓存池的容量,高频访问的Page反而会因为缓存池震荡而反复读写磁盘,引发严重的磁盘IO抖动。

其次是锁竞争和CPU消耗。WiredTiger虽然是文档级并发控制,但当多个请求同时访问同一个热点文档时,仍会产生大量的锁等待和上下文切换。别小看这个问题,在极端情况下,MongoDB的CPU时间有相当一部分消耗在锁管理上,真正执行查询的时间反而被压缩了。

最麻烦的还是慢查询的连锁反应。热点文档通常嵌在某个集合中,查询条件往往不带索引或者索引选择性差,这就会触发COLLSCAN。一个COLLSCAN在后台运行,不仅自己慢,还会抢占CPU和内存资源,拖慢所有正常查询。我这次遇到的问题,根因就是排行榜集合中有个文档被高频查询,但查询条件匹配了大量文档,导致每次查询都触发全表扫描。

所以,识别热点数据的第一要务不是“加个缓存”,而是建立感知能力——你得知道哪些文档在什么时间窗口内被访问了多少次。有了这个数据,缓存策略才有可能精准投放。

1.2 缓存分层架构:从单级缓存到两级缓存

在加缓存之前,我先梳理了一下现有系统的访问链路。当时的生产架构是标准的“客户端 -> 业务服务 -> MongoDB”,缓存层只有一个Redis,而且用的是非常原始的方式:每个接口先查Redis,命中就返回,没命中就去查MongoDB然后回填。

这套逻辑的问题很突出。第一,没有区分热数据和冷数据,全都丢进Redis里,导致Redis的内存利用率很低;第二,缓存失效时间统一设置,热点数据刚过期就被大量并发请求打穿;第三,没有本地缓存,每次请求都要走一次网络到Redis,延迟虽然比查MongoDB低,但仍有优化空间。

这次改造,我把缓存架构调整成了两层:本地Caffeine缓存作为L1,Redis作为L2,MongoDB作为最终数据源,也就是经典的L1+L2+DB模式。

![img](L1缓存基于应用实例内存,速度最快,通常在微秒级;Redis缓存作为分布式共享层,解决多实例间的数据一致性问题,耗时在毫秒级;MongoDB作为持久化存储。这个分层逻辑并不复杂,核心在于每一层缓存的数据形态和生命周期是完全不同的。

本地Caffeine缓存适合放那些写入频率极低、读取频率极高的热点数据,比如排行榜前100名的基础信息——这类数据即使短暂不一致,对业务的影响也不大。Redis缓存放的是需要多实例共享的缓存数据,比如用户维度的业务详情。MongoDB则始终作为数据的一致性和持久化底座。

双级缓存的好处不只是速度,更重要的是保护。L1缓存挡住了绝大多数重复请求,L2缓存兜底处理Redis层面的穿透,真正落到MongoDB的请求量可能只有原来的十分之一甚至更低。而且Caffeine本身支持基于访问频率的淘汰策略,配合热点识别逻辑,可以在缓存层面自动过滤掉已经冷却的数据,不需要人工干预。

1.3 为什么热点识别是整套方案的灵魂

很多团队做缓存优化,上来就配置Redis、调整过期时间,但效果往往不好,原因就是没有做热点识别。缓存本质上是一个“用什么数据填充”的问题,如果你不知道哪些数据是热的,那缓存就是盲目的——要么缓存太多,内存不够用;要么缓存太少,命中率上不去。

热点识别在整套方案中的角色,是大脑。它决定了三件事:哪些文档值得缓存、缓存多长时间、什么时候需要预热。没有这个大脑,再好的缓存组件也是一堆废铁。

具体到技术选型,我一开始考虑过用现成的中间件,比如某些网关或代理层自带的统计分析功能,但后来发现这些方案粒度太粗,只能统计到接口级别,无法精确到具体的文档ID。最终还是决定在业务代码里做埋点统计,虽然侵入性稍微强一点,但胜在精准可控。

2. 热点数据识别的三种落地方案与原理拆解

2.1 应用层计数方案:自己动手,丰衣足食

应用层计数是我最终采用的方案,也是最可控的方案。核心逻辑很简单:在查询MongoDB文档的必经路径上加一个计数器,记录每个文档ID在时间窗口内的访问次数。

具体实现上,我用了一个带时间窗口的计数Map,结构大致如下:

public class HotKeyCounter { // 存储每个文档ID的访问计数,key为文档ID,value为计数器 private final ConcurrentHashMap<String, AtomicLong> counterMap = new ConcurrentHashMap<>(); // 记录每个文档ID的首次访问时间,用于滑动窗口清理 private final ConcurrentHashMap<String, Long> timeMap = new ConcurrentHashMap<>(); // 统计窗口,比如10分钟 private static final long WINDOW_SIZE = 10 * 60 * 1000L; // 触发阈值的访问次数,比如5000次 private static final long HOT_THRESHOLD = 5000L; public void recordAccess(String docId) { counterMap.computeIfAbsent(docId, k -> new AtomicLong(0)).incrementAndGet(); timeMap.putIfAbsent(docId, System.currentTimeMillis()); } public boolean isHot(String docId) { AtomicLong counter = counterMap.get(docId); if (counter == null) { return false; } // 检查是否在窗口内 Long startTime = timeMap.get(docId); if (startTime == null || System.currentTimeMillis() - startTime > WINDOW_SIZE) { counterMap.remove(docId); timeMap.remove(docId); return false; } return counter.get() >= HOT_THRESHOLD; } }

这套方案的原理说白了就是“按时间段统计热度”。为什么用ConcurrentHashMap而不是加锁的HashMap?因为热点识别这个动作本身不能成为性能瓶颈,ConcurrentHashMap在读写并发下表现稳定,AtomicLong的原子自增也能保证在高并发下计数不丢。

统计窗口的设计也很关键。我用的是固定窗口而非滑动窗口——固定窗口实现简单,但存在边界问题;滑动窗口更精确,但内存开销更大。实际生产环境中,热点数据的突发性很强,固定窗口的误差完全在接受范围内。如果窗口过大,热点识别会滞后;窗口过小,则容易把短暂突刺误判为热点。我最终选择了10分钟窗口,这个值是根据业务访问峰值反复调出来的。

还有一个细节需要注意:每个文档ID的访问计数是无界增长的,如果长时间不清零,内存会持续膨胀。所以我在记录访问时间的同时,会在窗口过期后自动清理。更稳妥的做法是启动一个定时线程,定期扫描时间Map,把超过窗口时间的文档ID全部从两个Map中移除。

2.2 MongoDB原生辅助方案:profiling与慢查询日志

应用层计数虽然精准,但它的局限在于——只能统计到业务代码主动记录的数据。如果你的查询来自多个服务、多个入口,或者有直接通过MongoDB Shell执行的操作,应用层计数就会漏掉一部分。

MongoDB本身其实也提供了一些辅助手段。最常用的是开启数据库的profiling功能,将操作记录到system.profile集合中,然后定期分析哪些查询被频繁执行。

// 开启慢查询 profiling,记录执行时间超过100ms的操作 db.setProfilingLevel(1, 100)

开启之后,system.profile会记录所有符合条件的查询操作,包括查询语句、扫描的集合、执行时间等。这时候可以通过聚合分析来识别高频率的查询模式:

db.system.profile.aggregate([ { $match: { op: "query", ns: "mydb.mycollection" } }, { $group: { _id: { "collection": "$ns", "queryPattern": "$query" }, count: { $sum: 1 }, totalMillis: { $sum: "$millis" } } }, { $sort: { count: -1 } }, { $limit: 20 } ])

这个方案的本质是利用数据库自身的观测能力,定位“哪些查询模式占用了绝大多数执行时间”。它的优势是覆盖范围广,不管请求从哪里来,只要落到MongoDB就会被记录下来。缺点是profiling本身也有性能开销,生产环境不建议长期开启,而且它只能告诉你某个查询模式很慢,要定位到具体的文档ID,还得结合查询条件分析。

我实际的做法是:在出现性能问题时,临时开启profiling抓现场,定位到具体的热点集合和查询模式,再回到应用层针对性地加埋点。profiling不是日常监控工具,而是事故定位工具。

2.3 结合索引统计辅助识别

除了profiling,MongoDB还提供索引统计功能,可以通过$indexStats看到每个索引的使用频率:

db.mycollection.aggregate([ { $indexStats: {} } ])

输出结果会显示索引名称、访问次数、命中时间等指标。这个信息可以用来判断哪些字段经常作为查询条件,进而推断哪些集合存在热点访问模式。

不过说实话,$indexStats是集合粒度的统计,只能告诉你某个索引很热,无法告诉你具体哪个文档很热。它更适用于“优化索引设计”这个方向,对热点文档识别的直接帮助有限。我在整个方案中并没有把它作为核心手段,但在排查阶段确实帮了不少忙——定位到某个集合存在明显的高频索引访问后,再结合业务日志去锁定具体文档,效率会高出很多。

2.4 三种方案如何选型与组合

做完对比,我总结一下三种方案的适用场景:

方案实现成本精确度覆盖范围适用场景
应用层计数低,代码埋点高,精确到文档仅限接入的代码路径核心业务主链路
profiling分析中,需配合数据库操作中,定位到查询模式全量请求故障定位、临时分析
索引统计低,一条命令低,集合粒度全量索引索引优化、辅助判断

组合策略是最优解:先用应用层计数做主路线的热点识别,保证精确度;遇到性能故障时临时开启profiling做全量核查,防止有未埋点的路径漏掉;日常巡检用索引统计辅助发现不合理的查询模式,提前规避潜在热点。三管齐下,基本能把热点数据围得死死的。

3. 缓存策略设计:从命中率提升到保护MongoDB

3.1 两级缓存的结构设计与参数选择

热点识别只是第一步,识别出来的热点数据高频访问,缓存策略才能精准投放。我设计的缓存结构是:本地Caffeine作为第一级缓存,Redis作为第二级缓存,MongoDB作为持久层。

![img](在企业级MongoDB部署中,热点数据的访问大多都要经过应用节点,这使得Caffeine本地缓存拥有天然的靠近调用方的优势。对于那些识别为热点的文档,我会先在服务内存里缓存一份。实例直接访问内存,不需要网络开销,因此读写通常都在微秒级别。

具体参数我调了一段时间。Caffeine的maximumSize最初设置为1万条,后来发现对于热点集中的业务场景有点浪费内存——真正高频访问的文档可能只有几百个,但我决定还是保守一点,保持在1万条左右,因为Caffeine的淘汰机制是懒加载,如果实际访问量没上去,大部分容量其实是空置的,不影响性能。

Redis方面,我沿用已有的Redis集群,key的设计加入了业务前缀和文档ID,比如rank:doc:{docId}。这个key结构的好处是方便做批量删除和按前缀扫出所有热点缓存。

两级缓存的访问逻辑并不复杂:

// 伪代码:查询热点文档 public Document getHotDocument(String docId) { // L1: Caffeine Document doc = localCache.getIfPresent(docId); if (doc != null) { return doc; } // L2: Redis String redisKey = "rank:doc:" + docId; String json = redisTemplate.opsForValue().get(redisKey); if (json != null) { Document docFromRedis = JsonUtil.parse(json); localCache.put(docId, docFromRedis); return docFromRedis; } // DB: MongoDB Document dbDoc = mongoTemplate.findById(docId, Document.class); if (dbDoc != null) { redisTemplate.opsForValue().set(redisKey, JsonUtil.stringify(dbDoc), 30, TimeUnit.MINUTES); localCache.put(docId, dbDoc); } return dbDoc; }

需要注意的是,L1缓存和L2缓存的TTL策略不能一样。L1缓存的TTL设得比较短,比如1到2分钟,这是为了尽快感知到数据变更,避免脏数据被长时间留在本地内存。L2缓存有Redis承接,可以设得长一些,比如30分钟,主要用来兜底处理L1未被命中的请求。

3.2 Cache Aside模式与双删策略:缓存和数据库的一致性难题

缓存层加得越多,一致性处理的复杂度就越大。我在这次改造中选择了最经典、也最好维护的Cache Aside模式,它的核心思想是:

  • 读请求:先读缓存,未命中就读数据库,然后回填缓存。
  • 写请求:先更新数据库,然后删除缓存。

为什么不是先删缓存再更新数据库?因为如果先删缓存,紧接着有一个读请求进来,发现缓存为空就去读数据库,而这个时候数据库的更新还没提交,它就会把旧数据回填到缓存里。等到数据库更新完成后,缓存里的旧数据就成了脏数据。

那为什么是“更新数据库后删除缓存”而不是“更新数据库后更新缓存”?更新缓存有一个并发时序问题:如果两个写请求并发操作,后发的请求可能先完成,把旧的数据写入缓存,导致最终缓存里的数据是旧值。删除缓存的做法则没有这个问题——即使删除操作并发执行,下一次读请求总会把最新数据重新加载进来。

但Cache Aside模式也不是银弹,删除缓存本身也有时序风险。典型的问题是:一个写请求刚删完缓存,一个读请求发现缓存为空,立即查数据库,查出来的是更新前的旧数据,然后回填到缓存。在极端情况下,这个旧数据会在缓存里存活一段时间。

对付这个问题,我采用的策略是“延迟双删”——更新完数据库后,先删除一次缓存,等几百毫秒,再次删除一次缓存。这个延迟时间要大于数据库主从同步的延迟时间,保证第二次删除时,即使是读请求回填了旧数据,也会被删除干净。

public void updateDocument(String docId, Document newDoc) { // 1. 更新 MongoDB mongoTemplate.save(newDoc); // 2. 删除缓存(第一次) redisTemplate.delete("rank:doc:" + docId); localCache.invalidate(docId); // 3. 延迟后再次删除缓存 scheduledExecutor.schedule(() -> { redisTemplate.delete("rank:doc:" + docId); localCache.invalidate(docId); }, 500, TimeUnit.MILLISECONDS); }

不过延迟双删有个尴尬点:第二次删除是定时执行的,如果服务在这期间重启,删除操作就丢了。更可靠的方案是引入消息队列,把删除操作投递到MQ,由消费者异步处理,保证最终删除一定会执行。这个我没有在线上启用,因为目前的业务对一致性的容忍度还算可以,但如果你做的是电商库存、金融交易这类强一致场景,建议直接用MQ方案。

3.3 热点数据的TTL动态调整与逻辑过期

普通缓存的TTL是固定的,但热点数据不一样。热点数据最大的特征就是“持续被高频访问”,如果在访问高峰期间缓存过期了,那一次过期就会引发大量的MongoDB穿透请求。我这边遇到的最糟糕的案例是,某个热点文档的缓存恰好在大促开始时过期,Redis和本地缓存同时失效,结果数据库瞬间被打满。

所以,我对热点数据做了特殊处理:TTL不是固定的,而是根据访问热度动态调整。当某个文档被识别为热点后,它的缓存TTL会自动从30分钟延长到1小时,甚至更久。同时,热点状态本身也会定期刷新,一旦热度下降,TTL可以自动调回正常水平。

实现也不复杂,在识别到热点后,更新缓存时直接写一个更长的过期时间即可:

// 在缓存回填时,判断是否为热点,决定TTL时长 int ttlSeconds = hotKeyDetector.isHot(docId) ? 3600 : 1800; redisTemplate.opsForValue().set(redisKey, json, ttlSeconds, TimeUnit.SECONDS);

除了动态TTL,我还采用了“逻辑过期”策略来兜底。所谓的逻辑过期,就是缓存里不存原始数据,而是存一个包装对象,对象里包含数据本身和过期时间。读请求发现逻辑过期后,不会立即删除缓存,而是先尝试从MongoDB拉取新数据,同时只让一个请求去数据库拉取,其他请求继续返回旧数据。这就是常见的singleflight模式,可以极大降低缓存击穿对数据库的冲击。

public Document getDocumentWithLogicalExpiry(String docId) { // 从缓存获取包装对象 CacheWrapper wrapper = getFromCache(docId); if (wrapper == null) { return loadFromDbAndCache(docId); } if (wrapper.isExpired()) { // 只有一个请求能拿到互斥锁去刷新缓存 boolean lock = tryLock(docId); if (lock) { try { return loadFromDbAndCache(docId); } finally { releaseLock(docId); } } else { // 获取不到锁的请求继续返回旧数据 return wrapper.getData(); } } return wrapper.getData(); }

这种做法的核心价值在于,它把“缓存过期”对业务的影响降到了最低。正常情况下缓存过期只会带来一次数据库查询,但如果没有逻辑过期这种兜底机制,一次过期可能导致几十个并发请求同时打到MongoDB上,响应时间瞬间拉满。

3.4 缓存预热:别让冷启动变成事故启动

缓存预热这块,我吃过一次亏。当时上线新版本,服务重启之后,热点数据缓存全部清空,Redis里也没有任何预热的逻辑。结果重启完成后,首批请求全部穿透到MongoDB,虽然只持续了几十秒,但数据库的CPU和内存已经出现了明显的波动,监控上的慢查询数量也跟着涨了一大截。

从那以后,我把缓存预热做成了标准动作。每当服务启动或者热点识别发现有新的热点文档出现,就会自动触发预热任务:

@Component public class CacheWarmer { // 从MongoDB拉取历史热点TOP N文档 public void warmUp() { List<String> hotIds = hotKeyDetector.getTopHotKeys(100); for (String docId : hotIds) { Document doc = mongoTemplate.findById(docId, Document.class); if (doc != null) { redisTemplate.opsForValue().set("rank:doc:" + docId, JsonUtil.stringify(doc), 3600, TimeUnit.SECONDS); localCache.put(docId, doc); } } } }

预热的粒度不是越大越好。100个热点文档其实是合理的缓冲,既能覆盖绝大多数请求,又不会因为预热数量过多拖慢启动时间。事实证明这一百个预热条目在Redis中的内存占用极小,但带来的命中率提升是肉眼可见的。

4. 实操过程全记录:从识别热点到稳定运行

4.1 环境准备与基础配置

先把环境交代一下。数据库用的是MongoDB 4.4,WiredTiger存储引擎,副本集架构一主两从,服务器配置是8核16G。缓存用的是Redis 6.x集群,三主三从。业务服务是Spring Boot 2.7,Java 11。

MongoDB的连接池配置我做了调整。由于热点缓存上线后,数据库的压力会大幅降低,如果仍然维持过大的连接池,反而浪费系统资源。我把连接池上限从200降到了100,这个数量在缓存命中率达标后完全够用。

Spring Boot接入Caffeine和Redis缓存的方式比较常规,但有一个配置需要注意:

spring: cache: type: caffeine cache-names: localHotCache caffeine: spec: maximumSize=10000,expireAfterWrite=120s

这里我犯过一个错:一开始把expireAfterWrite设成了10秒,导致本地缓存几乎失去意义,数据刚被放进去还没等到复用就过期了。后来调成120秒之后,本地命中率才真正稳定在85%以上。所以参数的调整一定不能靠猜,要在压测下观察命中率和穿透量的真实表现。

4.2 热点识别模块的接入与阈值计算

我在业务代码中加了一层拦截逻辑,所有请求查询文档数据时,都会经过一个统一入口的recordAccess方法。这个入口可以理解为阅读器模式的访问统计,它记录每一次文档ID的访问。

热点阈值的计算,我用了一个比较粗粒度的经验公式:

假设某个业务接口在高峰期QPS是2000,其中80%的请求集中在20个文档上,每个文档的QPS就是80次/秒。如果统计窗口是10分钟(600秒),那么一个文档在一个窗口内的访问次数将是:

80次/秒 * 600秒 = 48000次

所以我把热点阈值定在了5000次/10分钟,这是一个相对保守的值。设置这个阈值的逻辑是,如果某个文档在10分钟内连5000次访问都达不到,说明它还不够热,缓存它的价值不大。反之,如果超过了5000次,缓存它的收益会非常明显。

4.3 完整链路联调与参数调整

整个链路联调的时候,碰到的问题比预想的多。最典型的是缓存击穿问题复现。虽然加了逻辑过期保护,但排查后发现,逻辑过期判断在Caffeine和Redis两级缓存之间的配合并不完美——Caffeine的过期时间到了后,数据从本地缓存被移除,而Redis里的数据还在有效期内,此时业务请求却因为L1未命中而直接跑到了L2查询Redis,走了两次网络。

解决方式是调整两级缓存的过期时间关系:Caffeine的过期时间必须小于Redis的过期时间。比如Caffeine设120秒,Redis设30分钟。这样,即使在L1过期后去走L2,Redis一定能命中,不会穿透到MongoDB。这个“时间差”的设置非常关键。

另一个值得留意的参数,是热点识别的统计窗口大小。我原本设的是5分钟,但上线后发现,有些访问集中在整点前后的业务场景,热点数据在5分钟窗口内经常被误判为“次热点”而得不到缓存资格。调整成10分钟窗口之后,误判率下降了很多。所以统计窗口的设定,一定要结合业务的访问周期性来看,不能盲目套用。

4.4 灰度上线与效果评估

改造完成后,我没有直接全量上线,而是先挑了一个流量比较低的业务分组做灰度。灰度期间观察了三个关键指标:缓存命中率、MongoDB CPU使用率、P99响应时间。

灰度第一天的数据反馈非常明显。缓存命中率从原来的55%提升到了92%左右,MongoDB主节点的CPU使用率从62%下降到21%,P99响应时间从320ms降到45ms。不过这些都是整体平均值,细分来看,热点接口的改善更明显,排行榜接口的P99直接从3s降到了80ms,体感上是质的飞跃。

全量上线后,我又持续盯了一周。比较有意思的是,数据库慢查询数量下降了接近80%,但并没有完全清零——那些漏网之鱼大多是没被识别为热点的尾部请求。后来我在排查时发现,尾部请求实际上根本不应该走热点缓存逻辑,它们应该交给正常的普通查询通道。这提醒了我,热点识别和缓存策略要分两条腿走路,不能一刀切。

5. 常见问题与排查技巧实录

5.1 缓存击穿:热点过期瞬间的集中穿透

缓存击穿是这次改造中执行得最艰苦一仗。热点文档缓存过期的一瞬间,大量并发请求同时发现缓存为空,然后全部涌向MongoDB。你可能觉得这只是一瞬间的事,但在高并发场景下,这一瞬间就足以让数据库的CPU飙到80%以上。

应对击穿,我采用的组合拳是:逻辑过期 + 互斥锁 + 热点TTL延长。互斥锁的实现用的是Redis的setnx命令,为每个热点文档设置一个带超时时间的锁,只有拿到锁的请求才能去数据库刷新缓存,其他请求要么返回旧缓存,要么短暂等待后重试。这套机制上线后,击穿问题基本没有再出现过。

注意:互斥锁的超时时间一定要设得合理。太短,可能在数据库查询还没完成时锁就失效了,导致多个请求同时穿库;太长,如果持有锁的服务崩溃,锁会成为死锁。我一般设的是2秒,配合数据库查询的P99时间动态调整。

5.2 缓存穿透:查询不存在的文档导致的无谓数据库压力

缓存穿透指的是查询那些在MongoDB中根本不存在的文档ID。每次查询都绕过缓存,直接打在数据库上,而数据库返回空结果,自然也不会回填缓存。如果是恶意攻击或者大量无效请求,这种穿透会让数据库非常受伤。

我的解决办法是缓存空值。当MongoDB查询结果为空时,也把这个文档ID写入缓存,不过存的是一个特殊标记,TTL设置的比较短,比如5分钟。这样在一个较短的时间窗口内,重复查询同一个不存在的文档ID时,会直接命中这个空值缓存,不会打到数据库。

有个细节需要注意:空值缓存的TTL不能太短,否则起不到保护作用;也不能太长,否则真的数据被插入时,这个ID在缓存里会持续一段时间“被判断为不存在”,影响业务。5到10分钟是我经过测试后觉得比较稳妥的区间。

5.3 Redis和本地缓存的数据一致性维护

双级缓存最大的痛点就是一致性问题。本地Caffeine缓存的数据,在服务实例A里更新了,但实例B的本地缓存里可能还是旧数据。Redis里的数据也是一样,多个实例同时读写,总会有时序不一致的情况。

我的处理方案是从两个维度来考虑。第一,对于本地Caffeine缓存,由于TTL短(120秒),就算出现短暂的不一致,影响面也很小。如果你对一致性要求更高,可以在Redis发布订阅频道上做失效通知,所有服务实例收到通知后主动清空本地缓存。第二,对于Redis缓存,延迟双删和MQ异步删除是两个备选方案,前者实现简单,后者可靠性高。我目前用的是延迟双删,因为代码侵入最少。

另外一个常见误区是:很多人会把所有的写操作都放到同一个服务里处理,以为这样就不存在一致性问题。但实际生产环境往往有多个服务在写同一个集合,这时候一致性策略就必须做成通用组件,所有写操作都必须走同一个缓存删除逻辑,否则任何一边漏掉了删除操作,缓存里的脏数据就会一直存在。

5.4 内存计数器膨胀引发的GC压力

这是在运维了一段时间后才暴露出来的问题。应用层计数方案中,ConcurrentHashMap里的文档ID和计数器数量持续增长——虽然每个窗口结束后会清理,但如果你的统计逻辑有Bug,或者窗口清理不及时,这些对象就会一直堆积在JVM堆里,触发频繁的FullGC,导致整个服务卡顿。

我遇到的情况是这样的:有一次我调整了窗口大小,从10分钟改成5分钟,但是没有同步修改清理线程的执行周期。结果导致旧时间窗口的数据没有被及时清理,Map越堆越大,服务响应时间出现周期性波动。排查后发现是因为GC线程占用太长时间,整个服务的可用性都在下降。

后来我把清理逻辑改成了定时执行,确保每个窗口结束后最多延迟30秒就清理所有过期数据。同时,计数器也改成了弱引用,尽量避免对象堆积。

再补充一点,统计模块最好单独拆分到一个独立的线程池里执行,避免阻塞正常业务请求。计数器本身的开销虽然小,但在极高的并发下,如果每次都走同步方法,也会对性能产生轻微影响。实际的优化做法是用LongAdder替代AtomicLong,它在高并发场景下的无锁设计能大幅减少CAS竞争。

5.5 慢查询优化与索引设计

最后聊聊MongoDB自身的优化空间。热点数据识别只是缓解压力的表层手段,真正要从根上提升访问速度,索引设计必须跟上。

这次处理的热点查询,最终锁定了两个高频查询模式:一个是按文档ID精确查询,一个是按状态字段加时间范围的查询。前者直接命中_id主键索引,性能没有问题;后者因为没有建立复合索引,每次查询都要扫描大量文档。

我针对第二个模式建立了复合索引:

db.mycollection.createIndex({ status: 1, updateTime: -1 })

建立复合索引需要考虑字段的区分度。status字段如果只有两三个值,区分度很低,把它放在索引第一位会浪费索引空间,也起不到很好的过滤效果。这里我分析了一下查询条件,最终选择了“status + updateTime”而非“updateTime + status”,原因是业务上绝大多数查询都带有status过滤条件,先按status过滤,再按updateTime排序,能有效减少扫描范围。

6. 最终效果与后续扩展思考

上线这套方案已经稳定运行了大概一个月。中间经历了一次小规模促销活动,流量大概是平时的三倍,MongoDB主节点的CPU最高峰值也只有45%左右,慢查询数量维持在一个非常低的水平。缓存命中率稳定在90%以上,热点文档相关的接口P99稳稳低于100ms。

我最大的体会是,缓存策略的落地不是一锤子买卖,它需要持续观察、持续调优。热点数据不是永远不变的,可能今天这个文档是热点,明天那个文档是热点,所以热点识别的实时性和准确性不能放松。我会定期拉取热点Top N列表进行人工确认,确保统计埋点覆盖到了所有关键业务路径——这一步非常值得做,因为业务逻辑一旦迭代升级,很可能新增接口没有接入热点识别模块。

后续如果这个方案要继续演进,我有两个方向。一是引入机器学习或者滑动窗口模型,尝试对热点数据进行预测,在业务请求还没达到峰值之前就完成缓存预热,这样能进一步提升缓存命中率,缩短MongoDB的负载低谷到高峰之间的爬坡时间。二是把热点识别模块独立成一个公共组件,做成SDK供团队内多个项目复用,避免每个项目都重复开发一遍这套逻辑。热点识别这件事,做得越标准化越省事,毕竟它本质上是通用的数据访问观测能力,不该绑定在具体业务上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询