MongoDB sharding 執行個體從 3.4版本 更新到 4.0版本 以後插入性能明顯降低,觀察日志發現大量的 insert
請求慢日志:
2020-08-19T16:40:46.563+0800 I COMMAND [conn1528] command sdb.sColl command: insert { insert: "sColl", xxx} ... locks: {Global: { acquireCount: { r: 6, w: 2 } }, Database: { acquireCount: { r: 2, w: 2 } }, Collection: { acquireCount: { r: 2, w: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 2709634 } } } protocol:op_msg 2756ms
日志中可以看到
insert
請求執行擷取 collection 上的 IS鎖 2次,其中一次發生等待,等待時間為2.7s,這與
insert
請求執行時間保持一緻。說明性能降低與鎖等待有明顯的相關性。
追溯日志發現 2.7s 前,系統正在進行 collection 中繼資料重新整理(2.7s的時長與collection本身chunk較多相關):
2020-08-19T16:40:43.853+0800 I SH_REFR [ConfigServerCatalogCacheLoader-20] Refresh for collection sdb.sColl from version 25550573|83||5f59e113f7f9b49e704c227f to version 25550574|264||5f59e113f7f9b49e704c227f took 8676ms
2020-08-19T16:40:43.853+0800 I SHARDING [conn1527] Updating collection metadata for collection sdb.sColl from collection version: 25550573|83||5f59e113f7f9b49e704c227f, shard version: 25550573|72||5f59e113f7f9b49e704c227f to collection version: 25550574|264||5f59e113f7f9b49e704c227f, shard version: 25550574|248||5f59e113f7f9b49e704c227f
chunk 版本資訊
首先,我們來了解下上文中的版本資訊。在上文日志中看到,shard version 和 collection version 形式均為 「25550573|83||5f59e113f7f9b49e704c227f」,這即是一個 chunk version,通過 "|" 和 "||" 将版本資訊分為三段:
- 第一段為
: 整數,用于辨識路由指向是否發生變化,以便各節點及時更新路由。比如在發生chunk 在 shard 之間遷移時會增加major version
- 第二段為
: 整數,主要用于記錄不影響路由指向的一些變化。比如chunk 發生 split 時增加minor version
- 第三段為
: objectID,辨別集合的唯一執行個體,用于辨識集合是否發生了變化。隻有當 collection 被 drop 或者 collection的shardKey發生refined時 會重新生成epoch
shard version 為 sharded collection 在目标shard上最高的 chunk version
collection version 為 sharded collection 在所有shard上最高的 chunk version
下文 “路由更新觸發場景” - “場景一:請求觸發” 中就描述了使用 shard version 來觸發路由更新的典型應用場景。
路由資訊存儲
sharded collection 的路由資訊被記錄在 configServer 的
config.chunks集合中,而 mongos&shardServer 則按需從 configServer 中加載到本地緩存(CatalogCache)中。
// config.chunks
{
"_id" : "sdb.sColl-name_106.0",
"lastmod" : Timestamp(4, 2),
"lastmodEpoch" : ObjectId("5f3ce659e6957ccdd6a56364"),
"ns" : "sdb.sColl",
"min" : {
"name" : 106
},
"max" : {
"name" : 107
},
"shard" : "mongod8320",
"history" : [
{
"validAfter" : Timestamp(1598001590, 84),
"shard" : "mongod8320"
}
]
}
上面示例中記錄的 document 表示該 chunk :
- 所屬的 namespace 為 "sdb.sColl",其 epoch 為 "5f3ce659e6957ccdd6a56364"
- chunk區間為 {"name": 106} ~ {"name": 107},chunk版本為 {major=4, minor=2},在 mongod8320 的shard上
- 同時記錄了一些曆史資訊
路由更新觸發場景
路由更新采用 "lazy" 的機制,非必須的場景下不會進行路由更新。主要有2種場景會進行路由重新整理:
場景一:請求觸發
mongos 收到用戶端請求後,根據目前 CatalogCache 緩存中的路由資訊,為用戶端請求增加一個 「shardVersion」 的元資訊。然後按照路由資訊将請求分發到目标shard上。
{
insert: "sCollName",
documents: [ { _id: ObjectId('5f685824c800cd1689ca3be8'), name: xxxx } ],
shardVersion: [ Timestamp(5, 1), ObjectId('5f3ce659e6957ccdd6a56364') ],
$db: "sdb"
}
shardServer 收到 mongos 發來的請求後,提取其中的 「shardVersion」 字段,并與本地存儲的 「shardVersion」進行比較。比較二者
epoch & majorVersion
是否相同,相同則認為可以進行寫入。如果版本不比對,則抛出一個
StaleConfigInfo
異常。對于該異常,shardServer&mongos 均會進行處理,邏輯基本一緻:如果本地路由資訊是低版本的,則進行路由重新整理。
場景二:特殊請求
- 一些指令執行一定會觸發路由資訊變化,比如
moveChunk
- 受其他節點行為影響,收到
指令,強制重新整理forceRoutingTableRefresh
- 一些行為必須要擷取最新的路由資訊,比如
cleanupOrphaned
路由重新整理行為

具體的重新整理行為分為兩步:
第一步:從config節點拉取權威的路由資訊,并進行CatalogCache路由資訊重新整理。實際最終是通過
ConfigServerCatalogCacheLoader
線程來進行的,構造一個
{
"ns": namespace,
"lastmod": { $gte: sinceVersion}
}
請求來擷取路由資訊。其中如果 collection的epoch發生變化或者本地沒有collection的路由資訊,那麼隻需增量擷取路由資訊,
sinceVersion
= 本地路由資訊中最大的版本号,即 shard version;否則
sinceVersion
= (0,0) ,全量擷取路由資訊。
ConfigServerCatalogCacheLoader
獲得到路由資訊以後,會重新整理 CatalogCache中的路由資訊,此時系統日志會列印上文中看到的:
2020-08-19T16:40:43.853+0800 I SH_REFR [ConfigServerCatalogCacheLoader-20] Refresh for collection sdb.sColl from version 25550573|83||5f59e113f7f9b49e704c227f to version 25550574|264||5f59e113f7f9b49e704c227f took 8676ms
第二步:更新
MetadataManager
(用于維護集合的元資訊,并提供對部分場景擷取一個一緻性的路由資訊等功能) 中的 路由資訊。更新
MetadataManager
時為了保證一緻性,會給 collection 增加一個 X鎖。更新過程中,系統日志會列印上文看到的第二條日志:
2020-08-19T16:40:43.853+0800 I SHARDING [conn1527] Updating collection metadata for collection sdb.sColl from collection version: 25550573|83||5f59e113f7f9b49e704c227f, shard version: 25550573|72||5f59e113f7f9b49e704c227f to collection version: 25550574|264||5f59e113f7f9b49e704c227f, shard version: 25550574|248||5f59e113f7f9b49e704c227f
這裡也即是文章開篇提到影響我們性能的日志,根因還是由于更新元資訊的X鎖導緻。
3.6+版本對 chunk version 管理的變化
那麼,為什麼 3.4版本 沒有問題,而到了 4.0版本 卻發生了性能退化呢?這裡直接給出答案:3.6&4.0的最新小版本當中,當shard進行 splitChunk 時,如果 shardVersion == collectionVersion ,則會增加 major version,進而觸發路由重新整理。而3.4版本中隻會增加 minor version。這裡首先來看下 splitChunk 的基本流程,随後我們再來詳述為什麼要做這樣的改動
splitChunk 流程
- 「auto-spliting觸發」:在 4.0及以前的版本中,sharding執行個體的 auto-spliting 是由 mongos 來觸發的。每次有寫入請求時,mongos都會記錄對應chunk的寫入量,并判斷是否要向 shardServer 下發一次
請求。判斷标準:splitChunk
。dataWrittenBytes >= maxChunkSize / 5(固定值)
- 「splitVector + splitChunk」:向 chunk所在的shard 下發一個
請求,擷取對該chunk進行拆分的拆分點。該過程會根據索引進行一定的資料掃描及計算,詳見: SplitVector指令 。若splitVector
擷取到了具體的拆分點,則再次向 chunk所在的shard 下發一個splitVector
請求,進行實際的拆分。splitChunk
- 「_configsvrCommitChunkSplit」:shardServer 收到
請求後,首先擷取一個分布式鎖,然後向 configServer 下發一個splitChunk
。configServer 收到該請求後進行資料更新,完成splitChunk,過程中會有 chunk 版本資訊的變化。_configsvrCommitChunkSplit
- 「route refresh」:上述流程正常完成後,mongos進行路由重新整理。
splitChunk 時,chunk version變化
在
SERVER-41480中,對splitChunk時,chunk version版本管理進行了調整
在3.4版本以及3.6、4.0較早的小版本中,「_configsvrCommitChunkSplit」 隻會增加 chunk 的minor version。
The original reasoning for this was to prevent unnecessary routing table refreshes on the routers, which don't ordinarily need to know about chunk splits (since they don't change targeting information).
根本原因是為了保護 mongos 不做必須要的路由重新整理,因為 splitChunk 并不會改變路由目标,是以 mongos 不需要感覺。
但是隻進行小版本的自增,如果使用者進行單調遞增的寫入,容易造成較大的性能開銷。
假設 sharding執行個體有2個mongos:mongosA、mongosB,2個shard:shardA(chunkRange: MinKey ~ 0),shardB(chunkRange: 0 ~ Maxkey)。使用者持續單調遞增寫入。
- T1時刻:mongosB首先判斷chunk滿足「auto-spliting觸發」 條件,向 shardB 發送「splitVector + splitChunk」,在請求正常結束後,mongosB觸發路由重新整理。此時,shardB 的 chunkRange 為 0 ~ 100,100 ~ Maxkey。
- 随後在一定時間内(比如T2時刻),mongosB無法滿足「auto-spliting觸發」 條件,而mongosA持續判斷滿足條件,向shardB發送 「splitVector + splitChunk」,但最終在「_configsvrCommitChunkSplit」步驟,由于 mongosA 的路由表不是最新的,是以無法按照其請求将 0 ~ Maxkey 進行拆分,最終無法成功執行。由于整個流程沒有完整結束,是以 mongosA 也無法進行 路由表更新,則在這段時間内持續會有這樣的無效請求。
而如上文描述的,
splitVector
根據索引進行一定的資料掃描、計算,
splitChunk
會擷取分布式鎖,均為耗時較高的請求,是以這種場景對性能的影響不可忽視。
在 SERVER-41480中對上述問題進行修複,修複的方式是如果 shardVersion == collectionVersion (即 collection上次的 chunk split 也發生在該shard上) ,則會增加 major version,以觸發各節點路由的重新整理。修複的版本為
3.6.15
,
4.0.13
4.3.1
4.2.2
而這個修複則導緻了開篇我們遇到的問題,确切些來說,任何在 shardVersion == collectionVersion 的 shard 上進行 split 操作都會導緻全局路由的重新整理。
官方修複
SERVER-49233中對這個問題進行了詳細的闡述:
we chose a solution, which erred on the side of correctness, with the reasoning that on most systems auto-splits are happening rarely and are not happening at the same time across all shards.
我們選擇了一個不完全正确的解決方案(SERVER-41480),理由是在大多數的系統中,auto-split很少發生,而且不會同時在所有shard上發生。
However, this logic seems to be causing more harm than good in the case of almost uniform writes across all chunks. If it is the case that all shards are doing splits almost in unison, under this fix there will constantly be a bump in the collection version, which means constant stalls due to StaleShardVersion.
然而,在對所有chunk進行均衡寫入的情況下,這個邏輯似乎弊大于利。如果這種場景下,所有的shard同時進行split,那麼在 SERVER-41480 修複下,collection version 将不斷出現颠簸,也就意味着不斷由于
StaleShardVersion
而導緻不斷暫停。
舉例來詳細說明下這個問題:假設某sharding執行個體有4個shard,各持有2個chunk,目前時刻major version=N。用戶端對sharding執行個體的所有chunk進行均衡的寫入,某時刻mongosA判斷所有chunk符合split條件,依次對各shard進行連續的 split chunk 觸發。為了便于說明,假設如圖所示,在T1,T2,T3,T4時刻,依次在ShardA、shardB、shardC、shardD進行連續的chunk split觸發,那麼:
- T1.1時刻 chunk1 發生 split,使得 shardA 的 shardVersion == collection;T1.2時刻 chunk2 發生 split,觸發 configServer major version ++ ,此時最新的major version=N+1;随後的T1.3時刻,shardA感覺後重新整理本地major version=N+1
- 随後的T2、T3、T4時刻依次發生上述流程。
- 最終在T5時刻,mongosA 在觸發完split chunk後主動重新整理路由表,感覺major version = N+4
那麼當系統中另外一個mongos(未發生更新,路由表中major version=N)向shard(比如shardB)發送請求時
- 在第一次請求互動後,mongosX感覺自身major version落後,與configServer互動,更新本地路由表後下發第二次請求
- 第二次請求中,shardB感覺自身major version落後,通過configServer拉取并更新路由表
- 在第三次請求中,雙方均獲得最新的路由表,而完成此次請求
- mongos&shard之間感覺路由表落後靠請求互動時的
來完成,而路由表更新的過程中,所有需要依賴該集合路由表完成的請求,都需要等待路由表更新完成後才能繼續。是以上述過程即jira中描述的:不斷由于StaleShardVersion
StaleShardVersion
同時 SERVER-49233 提供了具體的解決方案,在
3.6.19
、
4.0.20
4.2.9
及後續的版本中,提供
incrementChunkMajorVersionOnChunkSplit
參數, 預設為 false(即
splitChunk
不會增加major version),可在配置檔案或者通過啟動setParameter的方式設定為true。
而由于 auto-spliting 邏輯在 4.2版本 修改為在 shardServer 上觸發(
SERVER-34448), 也就不會再有 mongos 頻繁下發無效 splitChunk 的場景。 是以對于 4.4 版本,
SERVER-49433直接将增加 major version 的邏輯復原掉,隻會增加 minor version。(4.2版本由于中間版本提供了 major version 邏輯,是以提供
incrementChunkMajorVersionOnChunkSplit
來讓使用者選擇)
這裡對各版本行為總結如下:
- 隻會增加 minor version:
所有版本、3.4
之前的版本、3.6.15
4.0.13
4.2.2
(暫未釋出)4.4
- shardVersion == collectionVersion 會增加 major version,否則增加minor version:
~3.6.15
(包含)之間的版本、3.6.18
4.0.13
4.0.19
4.2.2
(包含)之間的版本4.2.8
- 提供
參數,預設隻增加 minor version:incrementChunkMajorVersionOnChunkSplit
及後續版本、3.6.19
4.0.20
及後續版本4.2.9
使用場景與解決方案
MongoDB版本 | 使用場景 | 修複方案 |
---|---|---|
4.2以下 | 資料寫入固定在某些Shard | 采用可以增加major version的版本(或設定 = true) |
資料在shard之間寫入較均衡 | 采用僅增加minor version的版本(或設定 = false) | |
4.2 | 所有場景 | |
4.2版本中已經跟進了官方修複。遇到該問題的使用者可以将執行個體更新到4.2的最新小版本,而後按需配置
incrementChunkMajorVersionOnChunkSplit
即可。