做Java后端的同学,尤其是项目里用了 MongoDB 的,应该都经历过类似的场景:某天线上突然接口超时,查了一圈发现是某个 Mongo 查询慢了,但具体是哪个操作、耗了多少毫秒、是否失败,完全没有头绪。数据库侧开 profiling 吧,影响又有点心疼;自己在 DAO 层加日志吧,每次写完查询都要手动加一行,漏了也正常。今天聊一个简单直给的方案:利用 MongoDB Java Driver 内部自带的CommandListener机制,把 Mongo 命令的执行耗时和结果自动埋点上报到我们内部的监控平台 Cat(这里只是代号,你们换成自研 APM 或者其它监控系统都行),实现对全部 Mongo 操作的“无侵入”监控。这套方案依赖驱动原生能力,实现成本低,可以落地到绝大多数使用 MongoDB 的 Java 服务里。
1. 为什么是 CommandListener:从监控痛点说起
1.1 我们到底想监控 Mongo 的什么
先明确需求。很多时候我们说要监控 Mongo,其实想回答的是这几个问题:哪个命令类型最慢?慢的是查询、插入还是聚合?落在哪个库、哪个集合上?有没有命令直接抛异常了?异常率是多少?具体某条慢命令耗时多久?
这些信息有两个典型用处。一是线上问题排查,接口超时后能快速定位到某条 Mongo 命令,而不是靠猜。二是容量规划与索引优化,当你发现某个集合上 find 命令平均耗时在飙升,大概率是索引没建好或者数据库出现了慢查询,提前介入就能避免事故扩大。
但 Mongo 的命令不是只有 find 和 insert,还有 aggregate、update、delete、count、distinct、findAndModify,甚至连创建索引、连接心跳(hello)这种管理命令也属于命令。业务代码里通常只关注增删改查,监控平台如果只能覆盖某几个方法,那必然有盲区。我们希望有一个全局视角,不管这个命令来自业务代码、ORM 框架,还是内部工具,都能被捕获到。
1.2 几种埋点方案对比
我试过几种常见的做法,各有各的难受。
第一种是 MongoDB 自带的慢查询日志(profiling)。开启 profiling level 2 之后,超过阈值的操作会写到system.profile集合里,看起来挺省事。但 profiling 是在数据库服务端执行的,开启后对整个实例所有库都有性能影响,生产环境一般不敢长期全开。另外它是事后记录,要定期捞日志才能在监控平台看到,做不到实时监控。
第二种是在 DAO 层包装一个工具类,所有 Mongo 操作都走这个工具类,然后工具类内部加计时和上报。问题很明显:团队里只要有人绕过工具类,直接用了 MongoTemplate 或者 MongoCollection,监控就漏了。而且不同人对工具类的用法五花八门,最后埋点参数经常对不齐。
第三种是搞代理层,比如用一个兼容协议的服务做转发。这个方案能监控到所有命令,但部署复杂,链路变长,性能损耗也大,中小团队一般不会为监控单独引入一层代理。
对比下来,CommandListener是目前最“正统”的方案。它是 Java Driver 官方提供的一组监听接口,客户端每次向 MongoDB 发送命令时,都会触发对应的回调事件。只要在创建MongoClient时注册一个监听器,就能感知到所有命令开始、成功、失败这三个阶段,不需要改任何业务代码。
1.3 CommandListener 的核心优势
我项目里的感受是:这个方案侵入性极低,接入成本可以忽略不计,监控范围完整,不存在“某个查询没走监听”的漏洞。它是在驱动层实现的,所以即使你用的是 Spring Data MongoDB,内部底层用的还是同一个 Java Driver,照样能触发。
另外一个好处是它不依赖数据库服务端配置。整个监控过程都在客户端完成,对数据库实例零负担。你可以在不影响线上 Mongo 的前提下,把每个命令的耗时、结果、异常全部拿下来。这正是我们做监控最想要的效果:不缺数据,不过度干预。
2. 核心接口与事件模型拆解
2.1 CommandListener 接口长什么样
在 MongoDB Java Driver 4.x 里面,CommandListener是个接口,定义了三个默认方法:
commandStarted(CommandStartedEvent event)commandSucceeded(CommandSucceededEvent event)commandFailed(CommandFailedEvent event)
三个方法都有默认空实现,意味着你不需要实现所有方法,只需要重写关心的回调即可。这个设计很友好,比如你只关心失败事件,那就只写commandFailed。
接口名里的 Command 指的是客户端发往 MongoDB 的 command 命令,也就是驱动把查询、更新等操作统一封装成的一种协议消息。所以有人把这种监听叫做“命令行监听”也不奇怪,这里的行 Listen 的其实是 Command,不是操作系统里的 CLI 命令行。
2.2 三类事件的核心字段与时机
生命周期是一条命令从开始到结束:先触发commandStarted,然后根据执行结果,要么触发commandSucceeded,要么触发commandFailed,二者不会同时触发。
事件对象上能拿到的关键字段主要有这些:
getCommandName():命令名,比如find、insert、aggregate。getDatabaseName():目标数据库名。getCommand():完整的命令 Document,里面包含集合名、过滤条件、排序规则等。注意这里可能包含查询参数和写入数据,后面讲脱敏问题。getRequestId():本次命令的唯一请求 ID,同一个命令的三个事件可以通过这个 ID 关联起来。getElapsedTime():从驱动发送命令到收到响应(或异常)之间的耗时,单位是纳秒。这是 4.x 版本才提供的便捷方法,低版本可能需要自己记录开始时间。getConnectionDescription():包含连接相关信息,可以拿到服务器地址、连接 ID 等。
从实际使用来看,requestId是最核心的关联依据。虽然事件本身提供了elapsedTime,但有些场景下你希望把命令名、集合名和耗时串成一条完整链路,用requestId做 key 最稳妥。另外如果想要统计某个操作 ID 下的多个命令(比如一条聚合操作可能拆成多个内部命令),事件里还有getOperationId(),这个代表一次用户操作内包含的命令集合,平时用得少。
2.3 注册监听器的正确姿势
监听器不是单独挂在某个MongoCollection上的,而是挂在MongoClientSettings上。创建MongoClient的时候通过addCommandListener注册进去,之后这个客户端发出的所有命令都会被监听。
注册代码很简单:
MongoClientSettings settings = MongoClientSettings.builder() .addCommandListener(new CatMongoCommandListener()) .applyConnectionString(new ConnectionString("mongodb://localhost:27017")) .build(); MongoClient mongoClient = MongoClients.create(settings);有一点要特别注意:监听器实例会被多个线程并发调用。一个MongoClient通常被整个应用共享,多个线程同时在执行查询,所以监听器内部不能持有可变状态,或者必须要用线程安全的数据结构。最简单的做法是让监听器无状态,需要临时保存的数据按requestId存到ConcurrentHashMap里。
3. 完整落地实现:从监听器到 Cat 埋点
3.1 基础依赖与准备
演示代码基于这些依赖:Java 8 以上,mongodb-driver-sync4.x。如果你的项目已经用了 Spring Data MongoDB,它传递依赖里自带驱动,直接复用即可。监控上报部分,我这里拿一个模拟的 Cat 客户端举例,核心 API 是创建 Transaction 并设置状态、记录耗时。你们换成自己监控系统的 SDK,只要实现同样的语义就行。
需要说明的是,Cat 的Transaction适合记录一段需要计时的操作,比如一个幅完整的事务或一次 RPC 调用;Event适合统计次数和耗时区间。我们监控的是命令耗时,天然适合用Transaction。
3.2 实现自定义 CommandListener
先定义一个简单的监听器,实现最核心的埋点逻辑。为了兼容旧版驱动(有的版本事件上没有elapsedTime),我用ConcurrentHashMap+System.nanoTime()自己记录开始时间。这样能覆盖更多版本,也让实现过程更透明。
import com.mongodb.event.CommandFailedEvent; import com.mongodb.event.CommandStartedEvent; import com.mongodb.event.CommandSucceededEvent; import com.mongodb.event.CommandListener; import org.bson.Document; import java.util.Arrays; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; public class CatMongoCommandListener implements CommandListener { private static final ConcurrentHashMap<Long, Long> START_TIME_MAP = new ConcurrentHashMap<>(); private static final List<String> COMMAND_COLLECTION_FIELDS = Arrays.asList( "find", "insert", "update", "delete", "aggregate", "count", "distinct", "findAndModify", "createIndexes", "drop" ); @Override public void commandStarted(CommandStartedEvent event) { START_TIME_MAP.put(event.getRequestId(), System.nanoTime()); } @Override public void commandSucceeded(CommandSucceededEvent event) { Long requestId = event.getRequestId(); Long startNanos = START_TIME_MAP.remove(requestId); if (startNanos == null) { return; } long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); String collectionName = extractCollection(event); report(event, collectionName, elapsedMs, null); } @Override public void commandFailed(CommandFailedEvent event) { Long requestId = event.getRequestId(); Long startNanos = START_TIME_MAP.remove(requestId); long elapsedMs = startNanos == null ? 0 : TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); String collectionName = extractCollection(event); report(event, collectionName, elapsedMs, event.getThrowable()); } private String extractCollection(CommandStartedEvent event) { Document command = event.getCommand(); if (command == null) { return "-"; } for (String field : COMMAND_COLLECTION_FIELDS) { if (command.containsKey(field)) { return String.valueOf(command.get(field)); } } return "-"; } private void report(CommandStartedEvent event, String collectionName, long elapsedMs, Throwable throwable) { String commandName = event.getCommandName(); String databaseName = event.getDatabaseName(); // 这里模拟 Cat 的 Transaction 埋点,换成自己监控系统的 SDK 即可 CatTransaction transaction = Cat.newTransaction("MongoDB", commandName); transaction.addData("database", databaseName); transaction.addData("collection", collectionName); transaction.addData("elapsed", elapsedMs + "ms"); transaction.setStatus(throwable == null ? CatTransaction.SUCCESS : CatTransaction.FAILED); transaction.complete(); } }这段代码的逻辑不复杂,但有几个细节值得展开说说。
第一,extractCollection的判断顺序。不同命令类型对应的集合名字段不一样:find 命令的集合名在find字段上,insert 命令的集合名在insert字段上。所以不能用固定的getString("collection")。上面这种“遍历可能字段”的方式是通用的。如果你的业务里只用特定几种命令,也可以只保留你关心的字段。
第二,commandStarted和commandSucceeded之间可能隔了网络 IO、服务端执行、结果序列化等所有阶段,所以自己用nanoTime算出来的耗时,比CommandSucceededEvent自带的elapsedTime可能略有偏差,但精度足够监控使用。如果驱动版本本身就支持getElapsedTime(),直接用自带的也行。两种方式我都用过,最终效果差异不大。
第三,使用ConcurrentHashMap时一定要注意清理。正常情况下commandSucceeded或commandFailed触发后都会remove掉对应 key。但如果驱动内部发生了不可预知的异常或者事件丢失,map 里可能残留未被清理的 key。设计上可以加一个带过期时间的缓存,或者定时清理不在处理中的 requestId,避免内存缓慢增长。高并发下这个 map 的 size 就是你活跃命令的并发数,数量级不大,可控。
3.3 在 MongoClient 中注册并接入 Spring Boot
监听器写好后,把它挂到MongoClientSettings上。如果是普通 Java 应用,像上面那样创建MongoClient就行。如果是 Spring Boot 项目,你很可能在依赖spring-boot-starter-data-mongodb后直接用MongoTemplate。这时需要自定义一个MongoClientSettings的 Bean,让容器创建的MongoClient使用我们注册过监听器的 settings。
import com.mongodb.ConnectionString; import com.mongodb.MongoClientSettings; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MongoConfig { @Bean public MongoClientSettings mongoClientSettings() { return MongoClientSettings.builder() .addCommandListener(new CatMongoCommandListener()) .applyConnectionString(new ConnectionString("mongodb://localhost:27017")) .build(); } }Spring Boot 的自动配置会优先使用你声明的MongoClientSettingsBean。注册完之后,所有通过MongoTemplate、MongoRepository的操作都会进入监听器。这里有个容易踩的坑:如果你的项目是基于旧版的MongoClient直接创建的,比如自己new MongoClient(new MongoClientURI()),那就要确保所有 MongoClient 的创建点都换成新写法,否则漏了某个 client 就监听不到了。
3.4 数据上报细节与示例
上面代码里我用了Cat.newTransaction这种伪代码。实际接入时,需要按你们监控平台的 API 调整。这里我展开说明为什么用 Transaction 而不用简单的 Event。
如果只用 Event,你只能记录“调用次数”和“耗时分布”,但无法在监控平台上看到某条命令的完整链路:开始在哪个上下文、结束在哪个上下文、失败原因是什么。Transaction 则可以记录完整的生命周期,还能手动设置状态为成功或失败。从监控平台的角度看,Transaction 天然形成一棵调用树,方便下钻。所以监听 Mongo 命令我推荐用 Transaction。
为了让示例更具体,我们可以做一个简化版的“采集上报器”:
public class MongoCommandReporter { private static final long SLOW_THRESHOLD_MS = 500; public static void report(String commandName, String database, String collection, long elapsedMs, Throwable throwable) { // 只上报慢命令和失败命令,降低对监控平台的压力 if (elapsedMs < SLOW_THRESHOLD_MS && throwable == null) { return; } // 模拟监控平台的上报 System.out.printf("[MONGO] command=%s db=%s collection=%s elapsedMs=%d failed=%s%n", commandName, database, collection, elapsedMs, throwable != null); } }这种取舍取决于你的监控平台容量。如果所有命令全量上报,量会非常大。常见做法是在监听器这一侧做一个简单的过滤:只上报超过阈值的慢命令、以及所有失败命令。我们内部是“全量日志 + 阈值告警”的组合:全量日志进离线分析,线上告警只看慢命令和错误命令,这样既不丢数据,也不会被高频命令刷屏。
另外还要考虑异步上报。commandSucceeded是在驱动处理响应的线程里回调的,如果你在这个方法里直接同步调用监控平台的网络接口,会拖慢 MongoClient 的响应处理线程,严重时甚至会影响业务请求。正确做法是把要上报的数据封装成一个对象,丢给独立线程池异步上报。
private final ExecutorService reportExecutor = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "mongo-monitor-reporter"); t.setDaemon(true); return t; }); // 在 report 方法里 reportExecutor.submit(() -> doReport(event, collectionName, elapsedMs, throwable));单线程上报的好处是顺序有保障,不会把监控平台打爆。需要注意线程池队列大小,如果量实在太大,可以丢弃旧数据或使用有界队列,避免内存积压。我在实际项目里用的就是有界队列 + 丢弃策略,因为监控数据丢失几条不影响大局,业务稳定优先。
4. 踩坑记录与排查技巧实录
4.1 常见问题排查速查表
接入过程中最容易碰到的问题,我整理成了一个对照表。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 监听器完全没有输出 | MongoClient 创建早于注册,或者注册进了另一个 settings | 检查所有 MongoClient 的创建点,确保都用同一个MongoClientSettings |
| commandFailed 之后又出现 commandSucceeded | 可能是驱动内部重试或者更高层封装后的二次触发 | 通过requestId标记状态,每次命令只上报一次 |
event.getCommand()里出现敏感查询参数 | 监听器拿到了完整命令体 | 对命令 Document 做脱敏白名单,只提取集合名等必要字段 |
| 高并发下 START_TIME_MAP 内存持续增长 | 事件丢失或者取消命令没触发完成回调 | 给 map 加定时清理,或者基于版本字段使用带过期时间的缓存 |
| 上报阻塞导致数据库操作变慢 | 在监听器里同步调用了监控 SDK | 改为独立线程池异步上报 |
| 管理类命令(hello、endSessions)也被统计 | 驱动自身会发送这些命令 | 在监听器里过滤getCommandName()为管理命令的情况 |
| 批量操作耗时被高估 | 一个insertMany里面可能拆成多次命令,每次命令都触发了监听 | 在采集层加上批量操作的统计粒度,按 operationId 聚合 |
这些坑我基本都踩过一轮。其中最容易忽略的是管理类命令。MongoDB 驱动在连接初建、认证、心跳等环节,会发送hello、buildInfo、endSessions之类的命令。如果不做过滤,你的 MongoDB 监控里会出现大量与业务无关的噪音,甚至“慢命令排行榜”被saslStart和hello占据,失去参考价值。
4.2 性能优化:采样、过滤、异步上报
一个监听器如果写得不好,完全可能成为性能瓶颈。我一般从三个方向控制开销。
第一是采样。不是所有命令都要全量上报。可以基于百分比采样,比如高峰期只保存 10% 的数据用于趋势分析;也可以只关注慢命令,比如耗时超过阈值才上报。实际经验是:慢阈值不能设得过低,否则会有大量 5ms、10ms 的普通请求进入告警,淹没重点。在我们系统里,查询类命令阈值是 200ms,写入类命令阈值是 100ms,因为写入通常要求更快。
第二是过滤。前面提到管理命令要过滤掉。还有几种场景要过滤:getMore命令(游标继续拉取,不代表新的一次用户操作,但其耗时和原查询强相关,可以合并统计)、killCursors(正常清理,没必要单列)。过滤器可以做成可配置的列表,方便线上动态调整。
第三是异步上报。监听器的回调方法应当尽可能轻量,只做记录和入队,不要做任何网络 IO。我之前做过一次测试,在同步上报的情况下,Mongo 操作的平均响应时间从 20ms 涨到了 40ms,几乎翻倍。改成异步之后,额外开销降到 1% 以内,完全可以接受。
4.3 进阶:按集合、命令类型做聚合统计
基础埋点上报的是每条命令的明细。到了监控平台上,如果你还想看趋势报表,可以直接用 Cat 的 Transaction 聚合能力。Cat 在服务端会按 Transaction 的 type 和 name 做汇总,比如type=MongoDB、name=find,就能看到所有 find 命令的耗时分布和调用量。
但这样的粒度还不够细。我想看到“某个库某个集合上的 find 命令”的耗时表现,所以我在监听器里把database + "." + collection拼进了 Transaction 的 name,把命令类型拼成了 type。比如:
- type:
MongoDB.find - name:
order_db.order_info
这样监控平台上的报表会呈现出按集合分的调用量、平均耗时、TP99 等指标。如果你想保留更丰富的维度,也可以用addData把 commandName 和 collection 都记进去。
聚合统计还会暴露一些有意思的现象。比如某个集合的aggregate命令平均耗时只有几十毫秒,但 TP99 是几秒钟。这种情况通常是因为聚合时有大批量排序或$lookup操作偶尔触发,普通聚合看不出来。有了聚合数据,才能针对性地优化索引或者拆分聚合管道。
5. 扩展思路与一些实在建议
5.1 慢命令告警怎么设计
埋点数据上报到监控平台后,告警策略是最后一步。我的经验是不要只看平均耗时,平均耗时很容易被大量快请求稀释。更合理的指标是 TP99 和失败率。比如设置“某集合 find 命令的 TP99 超过 500ms 持续 5 分钟”触发告警。这样能抓到真实影响,又不会因为偶尔一个慢查询就打扰大家。
另外,要区分基础聚合告警和业务告警。MongoDB 慢命令通常会在多个业务接口中体现,直接针对命令告警可能缺乏上下文。更好的做法是把命令关联到调用方,比如在埋点时把 TraceId 也加上,这样告警出来直接能看到是从哪个上游接口打过来的。
5.2 命令体脱敏与安全
我在前面提过event.getCommand()里包含完整命令体。比如一次 insert 操作,命令体里就是实际要写入的数据文档;一次 find 操作,命令体里是查询条件和排序字段。如果直接把整个命令体上报到监控平台,很可能把手机号、身份证号、订单金额这类敏感数据打到监控平台,万一监控平台被脱库,这就是一次数据泄露。
所以强烈建议只从命令体里提取集合名、命令类型等必要字段,不上报原始命令体。如果一定要上报部分内容做问题定位,也请先做脱敏处理,比如将查询条件中的字段值替换成***。监控数据的价值在于发现问题,而不是保存具体参数。别让埋点系统变成新的数据风险点。
5.3 与链路追踪系统的融合
现在的服务大多已经有链路追踪了。CommandListener触发时,可以尝试从当前线程的上下文里取出 TraceId,把它作为 addData 或 event 的维度一起上报。这样当一条调用链变慢时,你可以在链路页面直接看到“用户下单接口 -> 查询订单 -> MongoDB 聚合命令耗时 800ms”,定位效率会高很多。
实现方式上,要注意异步线程的上下文传递。如果监听器在异步回调线程里执行,直接拿当前线程的 TraceId 是拿不到的。这时候要么用requestId在命令开始时的线程里绑定上下文,要么通过驱动提供的上下文信息(比如 ConnectionDescription)去关联。不过大多数情况下,慢命令告警已经足够日常使用,链路融合属于锦上添花。
5.4 落地过程中的心态建议
最后说点实在的。写一个CommandListener本身并不难,难的是把它接入现有项目且不产生副作用。我在实际接入的时候,分了三步走:先在测试环境开了全量埋点,观察监控平台的数据是否合理;然后灰度发布到一个边缘服务,运行几天查看有没有内存泄漏、性能损耗;最后才全量铺开。这套流程虽然慢,但稳。监控系统如果反过来影响了业务,那就本末倒置了。
还有一个小技巧:监听器里打印日志时,不要用logger.info,至少要用logger.debug,否则你会被 Mongo 的驱动日志刷屏。尤其是刚接入那几天,看到不断刷出来的 command 日志,心里会很踏实,但日志系统可能会先顶不住。正式落地后,建议把日志级别调成 WARN,只输出失败和慢命令。
这套基于CommandListener的方案,短板也是有的:它只能覆盖 MongoDB Java Driver,如果你的服务里有其它语言的客户端,Python、Go、Node.js 各自需要重新实现一版监听。但对于 Java 技术栈居多的团队,这一份代码就能解决绝大部分 Mongo 监控问题。我个人落地之后最直观的感受是,线上再遇到 Mongo 慢查询,团队不再需要“开会猜”,而是直接打开监控看曲线,很快就能定位到是哪个集合、哪类命令引起的。这种安全感,值得每个用 Mongo 的 Java 项目拥有。