这是 MongoDB 分片集群写入路径系列的第 2/6 篇。
本文回答的问题:发生 socket 超时的写入会在服务器上停下吗?驱动的 retryWrites 与应用程序的重试同时开启时,一次写入会到达服务器几次?服务器上同时运行几份?maxTimeMS 能在多大程度上阻止这种重叠?
基准版本:MongoDB 4.2(源码r4.2.12标签,4.2 官方文档),MongoDB Java 驱动r5.4.0,retryable writes·driver sessions 规范(2026-11-05 获取的版本)。最新服务器版本的行为未经确认。
写入重试在两层分别发生。驱动在出现网络错误时,按 retryable writes 规则把同一写入再发送一次。应用程序(例如 Spring Kafka 消费者)收到驱动最终抛出的异常后,会从头重新执行写入。两者的次数相乘。
socketTimeoutMS 是客户端等待响应直到放弃的时间。服务器并不知道这个超时。在 MongoDB 4.2 中,即使客户端连接断开,insert·update·delete·findAndModify 仍会在服务器上继续运行。
驱动的重发会带上与原请求相同的会话 id(lsid)和事务编号(txnNumber)。服务器把两者视为同一写入,只执行一次。应用程序重试则是带着新会话或新编号的新写入。本文把同一写入在服务器引擎中被分别执行的情况称为副本。发往服务器的发送次数最多是应用程序执行次数的两倍。重叠运行的副本最多等于应用程序执行次数。
两者都是时间限制,但掌握时钟的一方不同。
| 项目 | socketTimeoutMS |
maxTimeMS |
|---|---|---|
| 掌握时钟的一方 | 客户端(驱动) | 服务器 |
| 时间到了时 | 驱动放弃读取 socket 并抛出异常 | 服务器在下一个中断检查点停止操作 |
| 服务器上的操作 | 不知情,继续运行 | 以 MaxTimeMSExpired 错误结束 |
| 设置位置 | 连接字符串、MongoClientSettings |
每个命令附带的参数 |
4.2 文档 "Terminate Running Operations" 这样说明 maxTimeMS。
When the operation reaches the specified time limit, MongoDB interrupts the operation at the next interrupt point.
interrupt point(中断检查点)是操作自行确认“是否有让我停下的标记”的代码位置。操作只有走到这个位置才会停下。这种方式称为协作式中断。第 4/6 篇会详细讨论。
服务器不论命令种类,都会读取通用参数 maxTimeMS 并为操作设定截止时刻(src/mongo/db/service_entry_point_common.cpp:817-830 (r4.2.12))。所以给 insert 命令加上 maxTimeMS 也会生效。
从 MongoDB 4.2 起,部分命令在客户端断开时会收到终止标记(与 killOp 相同的标记)。4.2 发行说明 "Client Disconnection" 一节列出了 10 个命令,包括 find、aggregate(不含 $out·$merge 时)、count、distinct 这类读取命令,以及认证·列表查询命令。
insert·update·delete·findAndModify 不在列表中。在源码中,调用断开时设置终止标记的函数 handleMarkKillOnClientDisconnect 的也只有上述读取命令的文件。写命令文件 write_commands.cpp 不调用这个函数。
收到终止标记的读取也是以协作方式停止。如果操作走不到中断检查点,读取同样会继续运行。
官方文档 "Retryable Writes" 的第一句话界定了 retryable writes 的范围。
Retryable writes let drivers retry specific write operations once after network errors or if they cannot find a healthy primary in the replica set or sharded cluster.
与 MongoDB 4.2 及以上兼容的驱动默认开启此功能。要关闭,在连接字符串中加入 retryWrites=false。重试对象是只修改一个文档的写入。
| 分类 | 例子 |
|---|---|
| 会重试 | insertOne、insertMany、updateOne、replaceOne、deleteOne、findOneAndUpdate 等 |
| 会重试 | 只包含上述单文档写入的 bulkWrite(ordered·unordered 均可) |
| 不重试 | updateMany、deleteMany,以及包含任一 multi: true update 或 limit: 0 delete 的 bulk |
| 不重试 | write concern 为 {w: 0} 的写入 |
socket 读取超时也属于重试对象错误。在 Java 驱动 r5.4.0 中,MongoSocketReadTimeoutException 继承自 MongoSocketException。CommandOperationHelper.isRetryableException 在遇到 MongoSocketException 时返回 true。retryable writes 规范也规定要给网络错误加上 RetryableWriteError 标签。
retryable writes 规范这样规定重发规则。
When retrying a write command, drivers MUST resend the command with the same transaction ID.
事务 ID 是 lsid(服务器会话 id)与 txnNumber(在该会话内递增的编号)的组合。mongod 收到带 txnNumber 的请求时,会先 check out 该会话。check out 是指把会话锁定为只供一个操作使用。service_entry_point_common.cpp:397-399 (r4.2.12) 的注释写道 "only requests with a transaction number will check out the session"。
原请求与重发争用同一个会话,因此结果只有两种。
无论哪种,实际执行写入的副本只有一个。重发会到达服务器,但不会制造额外的工作。
如果驱动的重发也失败,异常会上抛到应用程序。应用程序再次执行同一写入时,驱动把它当作第一次见到的写入处理。
lsid。txnNumber 也会递增。所以如果原操作仍在运行,两个操作会在服务器引擎中并行运行。
例如,Spring Kafka 的 DefaultErrorHandler(FixedBackOff(0L, 3L)) 以间隔 0 重试 3 次。最初 1 次加上 3 次重试,一条记录执行 4 次。不做任何设置的 DefaultErrorHandler 默认值是 FixedBackOff(0L, 9),一条记录执行 10 次。
| 项目 | 驱动 retryWrites | 应用程序重试(以 Spring Kafka 为例) |
|---|---|---|
| 设置 | retryWrites=true(4.2 兼容驱动的默认值) |
DefaultErrorHandler 的 BackOff |
| 何时 | 网络错误、找不到 primary 时 | 监听器抛出异常时 |
| 几次 | 再 1 次(不使用 timeoutMS 时) |
按设置的重试次数 |
| 重新执行什么 | 失败的那一个命令(bulk 时为该 batch) | 整个监听器处理 |
| 间隔 | 重新选定服务器后立即 | 由 BackOff 决定的等待 |
| 在服务器看来 | 相同的 lsid·txnNumber,同一写入 |
新的 lsid 或新的 txnNumber,新写入 |
下面是虚构的示例,数字也是用于说明的值。
store_inventory 集合发送 bulk 写入。socketTimeoutMS 为 30 秒,retryWrites 为默认值(开启)。DefaultErrorHandler(FixedBackOff(0L, 3L)),全部失败后把记录发往 DLQ(dead letter 主题)。| 时刻 | 客户端 | 服务器(假设) |
|---|---|---|
| 0 秒 | 应用程序第 1 次执行。发送 bulk(会话 A,编号 1) | 副本 1 开始运行 |
| 30 秒 | socket 超时。驱动重发同一 bulk(A, 1) | 重发等待会话 A。副本 1 个 |
| 60 秒 | 重发也超时。异常上抛到应用程序。应用程序第 2 次执行(B, 1) | 副本 2 开始运行。副本 1 也继续运行 |
| 90 秒 | 驱动重发(B, 1) | 等待会话 B。副本 2 个 |
| 120 秒 | 应用程序第 3 次执行(C, 1) | 副本 3 个 |
| 180 秒 | 应用程序第 4 次执行(D, 1) | 副本 4 个 |
| 240 秒 | 第 4 次执行的重发也超时。重试耗尽。发往 DLQ | 4 个副本仍在运行 |
在应用程序放弃该记录的 240 秒时刻,服务器上残留着同一 bulk 的 4 个副本和 4 个等待会话的重发。服务器操作一旦恢复,4 个副本都会被执行。
서버로 간 전송 최대 = 애플리케이션 실행 수 × 2
엔진에서 겹쳐 도는 사본 최대 = 애플리케이션 실행 수
上例中应用程序执行次数为 4。发送最多 8 次,副本最多 4 个。如果是默认值 FixedBackOff(0L, 9),则发送 20 次、副本 10 个。
以下两种情况重试会减少,达不到最大值。
这个计算针对的是一条记录。如果多个消费者陷入同样的情况,副本数会再乘以消费者数。
把 maxTimeMS 设得比 socket 超时短,服务器就会在客户端放弃之前先停止操作并返回 MaxTimeMSExpired。这个错误不是网络错误,所以驱动不会重发。重试重叠也相应减少。
MaxTimeMSExpired(代码 50)不属于重试对象,依据有三点。
ExceededTimeLimit(262)是另一个代码。RetriableError 类别的错误加 RetryableWriteError 标签(src/mongo/db/error_labels.cpp,r4.4.0)。在同一标签的 error_codes.yml 中,MaxTimeMSExpired 的类别是 Interruption 和 ExceededTimeLimitError。r5.4.0 的 RETRYABLE_ERROR_CODES 列表中没有 50。maxTimeMS 还会清理排队的操作。MongoDB 用 ticket(入场券)限制同时进入引擎的写入数。等待 ticket 或全局锁的过程是可中断的,所以截止时间一过,等待中的操作就会退出。
在引擎内部走不到中断检查点的操作,maxTimeMS 也无法切断。第 3/6 篇中的 WiredTiger 循环就是一例。这时服务器守不住截止时间,于是 socket 超时先发生,上面的乘法照样成立。
在 Spring Data MongoDB 中,只有查询可以用 Query.maxTime(Duration) 指定 maxTimeMS。BulkOperations 没有可传递的选项。Spring Data MongoDB 4.4.0 的 BulkOperations 和 DefaultBulkOperations 源码中一次也没有出现 maxTime。
Java 驱动的 CSOT(Client-Side Operation Timeout,timeoutMS)是把服务器执行也一并约束在内的时间限制。官方文档写道,设置 timeoutMS 后写入可以重试多次。此时上面的两倍计算不成立。
currentOp 中,针对同一集合的写入比应用程序执行次数还多。按 lsid 和 txnNumber 分组,组数就是副本数。FixedBackOff 会立即叠加副本。用 ExponentialBackOffWithMaxRetries 拉长间隔,消费者多时再加上抖动(随机扰动)。即使留了间隔,只要服务器停住,副本仍会累积。间隔能减缓累积速度,并为服务器争取恢复时间。maxTimeMS 设得比 socket 超时短。例如 maxTimeMS 20 秒,socketTimeoutMS 30 秒。服务器先停止,就不会产生驱动重发。max.poll.interval.ms(Kafka 默认值 300000ms)。超过时消费者会离开消费组,偏移量提交会失败(韩语)。Spring Kafka 默认的 BackOffHandler 在等待期间会让消费者线程休眠。留足余量的基准式如下。max.poll.records × (한 번 실행 최대 시간 × 실행 수 + 대기 합) < max.poll.interval.ms
单次执行最长时间按 socket 超时 × 2 估算。以上面的示例来说,一条记录执行 4 次要花 240 秒。_id 做 upsert 的写入,重叠多少次结果都一样。由驱动每次新生成 _id 的 insert,每次应用程序重试都会多出一个文档。updateMany·deleteMany 不会被 retryWrites 重发。失败时无法知道是否只反映了一部分,因此要另行判断应用程序重试是否安全。DefaultErrorHandler.addNotRetryableExceptions(...) 指定。
- 不要把 socket 超时理解为取消。 客户端放弃的写入仍在服务器上继续运行。
- 关闭 retryWrites 并不能消除重叠。 服务器副本数由应用程序执行次数决定。关闭 retryWrites 只会让发往服务器的发送次数减半。
- retryWrites 保护不了
deleteMany。 发生网络错误时,驱动不重发而直接抛出异常,删除了多少条无从得知。- 不要把
maxTimeMS当作万能截止时间。 走不到中断检查点的引擎内部操作,过了截止时间也会继续运行。
这是未经实际运行验证的步骤。下面的结果是阅读源码和规范后得出的预期。
需要准备两样东西。
--setParameter enableTestCommands=1 启动 mongod。在 4.2.12 中,configureFailPoint 被注册为测试命令(src/mongo/db/commands/fail_point_cmd.cpp 中的 MONGO_REGISTER_TEST_COMMAND)。步骤如下。
failCommand fail point,把 appName 为 retry-lab 的连接的 insert 每次阻塞 60 秒。db.adminCommand({
configureFailPoint: "failCommand",
mode: { times: 8 },
data: { failCommands: ["insert"], blockConnection: true,
blockTimeMS: 60000, appName: "retry-lab" }
})
socketTimeoutMS=5000&retryWrites=true&appName=retry-lab 连接另一个 shell。_id。for (let i = 1; i <= 4; i++) {
try { db.retry_lab.insertOne({ productId: 1, storeId: 7, attempt: i }); break; }
catch (e) { print(`attempt ${i}: ${e.name}`); }
}
db.getSiblingDB("admin").aggregate([
{ $currentOp: { allUsers: true } },
{ $match: { "command.insert": "retry_lab" } },
{ $group: { _id: { lsid: "$command.lsid.id", txn: "$command.txnNumber" },
n: { $sum: 1 } } }
])
db.retry_lab.countDocuments({ productId: 1 }) 统计文档数。如果 shell 的驱动在 socket 超时后会重发,预期结果如下。
attempt 1 到 attempt 4 大约每 10 秒输出一条。一次执行耗用原始发送 5 秒和重发 5 秒。n 为 2。fail point 在 service_entry_point_common.cpp:715 阻塞命令。会话 check out 发生在更靠后的第 912 行 runCommandImpl 调用内部。所以在这个实验中,重发也不会等待会话,而是与原请求一起被 fail point 阻塞。_id 写入。驱动的 4 次重发不会增加文档。
- 会话等待的服务器源码:重发会等到原请求结束这一说法,依据的是 driver sessions 规范。没有与 4.2 服务器的
session_catalog.cpp直接对照。- 分片集群路径:mongos 把
lsid和txnNumber传给 shard、shard 的 mongod 再 check out 会话的路径,没有沿源码追踪。本文对服务器行为的描述以 mongod 为准。- CSOT 的适用范围:Java 驱动
timeoutMS的引入版本、对 bulk 写入的适用范围,以及此时最多重试几次,均未确认。- 抖动支持:Spring 的
ExponentialBackOff系列是否支持抖动,没有按版本确认。- 复现步骤中的驱动:mongosh 使用的 Node.js 驱动在 socket 超时后是否重发写入,没有用该驱动的源码确认。如果不重发,第 4 步的
n为 1。$currentOp字段位置:lsid和txnNumber是否原样出现在command文档中,没有通过实际运行确认。- 最新服务器版本:4.2 之后断开时收到终止标记的命令列表是否有变化,未经确认。4.2 是已结束支持的版本。