本文由原作者授權網易雲發布,未經許可,謝絕轉載!

作者:溫正湖,網易資料庫專家

來源:資料庫內核

本文是MySQL Group Replication(MGR)不足和優化系列文章的延續。在之前的文章中講了事務認證機制/衝突檢測資料庫不足和優化。其中的優化點在我們的InnoSQL 5.7.20-v3b版本上實現,其優化效果也得到了初步驗證。本篇文章主要分析作為MGR最底層的節點間網路通信層(Paxos)存在的問題及其優化。

前言

從InnoSQL 5.7.20 GA到InnoSQL 5.7.20-v3b版本,我們持續對MGR進行著優化。截止目前,MGR已經在多個業務場景上得到了驗證,應該說在絕大部分業務場景下MGR都能夠運行良好。但在測試過程中,我們也發現在一些特別的場景下仍存在問題,典型的場景是在批量導數據時,比如使用NDC在不同MySQL實例間遷移數據。或者使用sqoop工具將數據從大數據系統遷移到MySQL實例上。為了提高遷移效率,常常會將多條記錄batch成一個事務進行批量插入,同時增加並發度。一般來說,如果待遷移的表中每條記錄的大小較小時,並不會有什麼問題。但若記錄本身較大,或batch個數較多,再加上並發度較大時。往往會導致遷移過程中mysqld佔用的內存不斷增漲。若mysqld增漲的內存超過了系統可用內存,則會引發OOM。

上圖是我們在某個業務表上使用NDC進行數據遷移,設置的參數是5個事務並發,每個事務200條記錄(約30MB),圖中展示了3次測試過程中的內存曲線。可以看到整個過程內存非常平穩。當我們把並發數從5提高為10時,情況就變得比較糟糕。如下圖所示。

在10並發場景下,隨著測試的進行,mysqld內存線性增漲。測試結束後,內存又快速恢復到初始值。如果數據量較大,遷移時間較長,那麼就可能導致mysqld OOM。尤其是在雲環境下,每個mysqld允許的內存增長空間有限的情況下問題會更加突出。

我們在MySQL主從複製部署模式下也進行了相同的測試。結果發現內存並沒有出現線性增漲的情況。所以確認是MGR模塊導致的。

問題現象及解釋

在內存增長期間,我們觀察了節點的網卡流量均已達到瓶頸。同時mysqld系統日誌中也會出現2類不尋常的日誌信息,下面逐一進行分析。

週期性廣播消息紊亂

第一類日誌如下:

```
[Warning] Plugin group_replication reported: The member with address 10.177.12.212:3331 has already sent the stable set. Therefore discarding the second message.
[Warning] Plugin group_replication reported: The member with address 10.177.12.211:3331 has already sent the stable set. Therefore discarding the second message.
```

該日誌對應的代碼位於certifier.cc中,也就是說是與衝突檢測/事務認證模塊相關的,具體代碼如下:

```
/*
As member is already received we can throw the necessary warning of the
member message already received.
*/
Group_member_info *member_info=
group_member_mgr->get_group_member_info_by_member_id(gcs_member_id);
if (member_info != NULL)
{
log_message(MY_WARNING_LEVEL, "The member with address %s:%u has "
"already sent the stable set. Therefore discarding the second "
"message.", member_info->get_hostname().c_str(),
member_info->get_port());
}
```

在上一篇文章中曾提到:

MGR每個節點都以固定的時間間隔(MySQL社區版為60s,在我們InnoSQL中可通過group_replication_broadcast_gtid_executed_period參數可調)來廣播自己的gtid_executed信息,節點收集了MGR集羣中各個節點的gtid_executed信息後,取交集來清理衝突檢測資料庫中不再需要的writeset信息(包括事務更新的主鍵/唯一鍵信息、該鍵值對應的快照版本和用於確定並行複製行為的sequence_number)。

我們假設MGR的3個節點分別是10.177.12.211:3331、10.177.12.212:3331和10.177.12.213:3331,其中10.177.12.213:3331為Primary節點。該日誌的意思是在沒有收到10.177.12.213:3331節點gtid_executed消息的情況下又重複收到了其他2個節點的新消息。問題的重點是10.177.12.213:3331為什麼沒有廣播gtid_executed消息呢?

節點的gtid_executed廣播間隔/週期一般為20s,如果各個節點週期一樣且都存活的情況下(確認如此),顯然不應該出現這種情況。為什麼?我們先提出疑問。

獲取Paxos日誌/消息失敗

再看第二類:

```
[Note] Plugin group_replication reported: dispatch_op /home/hzwenzhh/build-dir/rapid/plugin/group_replication/libmysqlgcs/src/bindings/xcom/xcom/xcom_base.c:3847 die_op executed_msg={1355c950 6746 1} delivered_msg={1355c950 6746 1} p->synode={1355c950 6748 2} p->delivered_msg={1355c950 6764 0} p->max_synode={1355c950 6763 2}
```

什麼時候會列印該日誌呢。我們從xcom_base.c代碼裡面看看die_op是怎麼回事:

```
if (/*ep->p->op == prepare_op && */ **was_removed_from_cache**(ep->p->synode)) {
DBGOUT(FN; STRLIT("send_die ");
STRLIT(pax_op_to_str(ep->p->op));
NDBG(ep->p->from, d); NDBG(ep->p->to, d);
SYCEXP(ep->p->synode);
BALCEXP(ep->p->proposal));
if (get_maxnodes(site) > 0) {
pax_msg * np = NULL;
np = pax_msg_new(ep->p->synode, site);
ref_msg(np);
np->op = die_op;
np->to = ep->p->from;
np->from = ep->p->to;
np->delivered_msg = get_delivered_msg();
np->max_synode = get_max_synode();
DBGOUT(FN; STRLIT("sending die_op to node "); NDBG(np->to, d);
SYCEXP(executed_msg); SYCEXP(max_synode); SYCEXP(np->synode));
serialize_msg(np, ep->rfd.x_proto, &ep->buflen, &ep->buf);
if(ep->buflen){
int64_t sent;
TASK_CALL(task_write(&ep->rfd , ep->buf, ep->buflen, &sent));
send_count[ep->p->op]++;
send_bytes[ep->p->op] += ep->buflen;
X_FREE(ep->buf);
}
ep->buf = NULL;
unref_msg(&np);
}
}
```

簡單來說,就是某個節點A向其他節點B發送請求某個(已經達成majority的)消息(消息有價值的部分是其中的value,可能是事務數據或MGR的週期性狀態廣播數據等)時,因為Paxos cache的大小有限,在節點B上,該消息已經從cache中移除(was_removed_from_cache),所以B就給A回復一個die_op消息。如果A向另一個節點C也無法獲取該消息時,那麼節點A就會列印如下消息並退出。

```
[ERROR] Plugin group_replication reported: Node 1 unable to get message, process will now exit. Please ensure that the process is restarted
```

對應代碼邏輯:

```
case die_op:
/* assert("die horribly" == "need coredump"); */
{
GET_GOUT;
FN;
STRLIT("die_op ");
SYCEXP(executed_msg);
SYCEXP(delivered_msg);
SYCEXP(p->synode);
SYCEXP(p->delivered_msg);
SYCEXP(p->max_synode);
PRINT_GOUT;
FREE_GOUT;
}
/*
If the message with the number in the incoming die_op message
already has been executed (delivered), then it means that we
actually got consensus on it, since otherwise we would not have
delivered it.Such a situation could arise if one of the nodes has
expelled the message from its cache, but others have not. So when
sending out a request, we might get two different answers, one
indicating that we are too far behind and should restart, and
another with the actual consensus value. If the value arrives
first, we will deliver it, and then the die_op may arrive later.
But it this case it does not matter, since we got what we needed
anyway. It is only a partial guard against exiting without really
needing it of course, since the die_op may arrive first, and we
do not wait for a die_op from all the other nodes. We could do
that with some extra housekeeping in the pax_machine (a new bit
vector), but I am not convinced that it is worth the effort.
*/
if(!synode_lt(p->synode, executed_msg)){
g_critical("Node %u unable to get message, process will now exit. Please ensure that the process is restarted",
get_nodeno(site));
exit(1);
}
```

從注釋中我們可以發現,B分別向A和C發送請求獲取對應消息時,如果C節點還保有該消息,A節點上該消息已經被移除,那麼B節點的mysqld是否退出取決C和A節點回復B的消息誰先到達。如果C先達到,那麼僅列印die_op日誌,如果A先到達,則列印ERROR日誌,mysqld退出。

無論mysqld是否退出,列印上述日誌已經意味著該節點的Paxos日誌落後其他節點很多。對於3節點的MGR集羣,一個消息要達成majority至少需要2個節點參與,那麼會有一個節點存在paxos日誌延遲。

問題危害性

MGR在網路不好和大事務場景下性能和穩定性較差是眾人皆知的,MySQL官方也是承認這點的。但並沒有具體說明是什麼原因導致的。而且,就我們的測試場景而言,30MB左右的事務並不能算很大的事務,該問題若不解決的話就需要業務遷就資料庫做出妥協,比如限制NDC或sqoop導數據時的並發數或batch大小,要求業務不能進行頻繁地在一個事務中進行大批量的數據DML操作。顯然這不是搞資料庫內核的人希望看到的。但什麼模塊會大量佔用內存呢?結合MGR的模塊示意圖進行分析:

問題分析和定位

在這之前,我們已經對MGR上層的事務認證模塊做了比較徹底的優化,可以比較有信心的認為內存增漲不大可能由事務認證模塊引起的,況且該模塊也不可能直接影響到網路層。所以,先排除了事務認證模塊的影響,把目光分別向上和向下看,向上懷疑Plugin Hook層是否存在事務堆積,向下懷疑Xcom/Paxos層佔用過多內存和網路資源(由於出現問題時網路已經處於瓶頸狀態,這個可能性更大)。下面一一進行分析。

Plugin Hook層事務堆積

每個本地事務執行到commit階段時,都需要通過Hook進入MGR層處理,如果MGR層處理過慢,就可能導致MySQL Server層的事務在MGR入口處堆積,對應的代碼處理邏輯是group_replication_trans_before_commit。恰巧,該函數裡面有個隊列很可疑:

```
/*
Map to store all open unused IO_CACHE.
Each ongoing transaction will have a busy cache, when the cache
is no more needed, it is added to this list for future use by
another transaction.
*/
typedef std::list<IO_CACHE*> IO_CACHE_unused_list;
static IO_CACHE_unused_list io_cache_unused_list;
```

事務commit時會將事務的DML操作和產生的writeset帶入MGR,在MGR中使用IO_CACHE對象將writeset格式化為Binlog Event,跟事務的其他Binlog Event一起封裝為事務消息傳遞給Paxos。如下所示:

嫌疑在於:事務完成認證並返回給MySQL Server層後,對應的IO_CACHE對象內存並不會立即釋放,而是緩存起來供後續事務復用,如下所示:

```
/*
Save already initialized cache for a future session.

@param[in] cache the cache
*/
void observer_trans_put_io_cache(IO_CACHE *cache)
{
DBUG_ENTER("observer_trans_put_io_cache");

io_cache_unused_list_lock->wrlock();
io_cache_unused_list.push_back(cache);
io_cache_unused_list_lock->unlock();

DBUG_VOID_RETURN;
}
```

如果事務較大,且並發較高,那麼就會佔用可觀的內存。為此,我們加了些debug信息來統計io_cache_unused_list中IO_CACHE個數和總大小,測試結果表明,其並沒有佔據多少內存空間。這個結果是可以理解的,因為NDC導數據場景,事務並發數是固定的,導入操作時同步的,比如10個並發,那麼在當前10個事務未返回前,下一批10個事務是不會執行的。所以不會有事務堆積在MGR層的入口。

Xcom層Paxos cache大小超限

既然向上看的模塊沒有問題。那就再看看Xcom/Paxos層了。對於Paxos層,我們已知其有個Paxos cache,最大可以緩存50000個paxos消息(包括正在進行Paxos協議操作的消息和已經達成Paxos協議的消息,後者會通過回調函數返回MGR上層處理,同時其他未參與majority的節點也會請求獲取這些已經達成Paxos協議的消息)。

```
/*
We require that the number of elements in the cache is big enough enough that
it is always possible to find instances that are not busy.
Under normal circumstances the number of busy instances will be
less than event_horizon, since the proposers only considers
instances which belong to the local node.
A node may start proposing no_ops for instances belonging
to other nodes, meaning that event_horizon * NSERVERS instances may be
involved. However, for the time being, proposing a no_op for an instance
will not mark it as busy. This may change in the future, so a safe upper
limit on the number of nodes marked as busy is event_horizon * NSERVERS.
*/
#define CACHED 50000
```

如果每個paxos消息的大小為1MB,那麼整個cache可能會佔用50G的內存空間,所以,在MySQL社區版中,對cache大小硬編碼限制為1GB。(在我們InnoSQL中,支持通過參數group_replication_xcom_cache_size_limit動態調整cache的大小閾值。)

```
/* Reasonable initial cache limit */
#define CACHE_LIMIT 1000000000ULL
```

當cache大小超過閾值後,會調用shrink_cache()函數進行清理。

```
/*
Loop through the LRU (protected_lru) and deallocate objects until the size of
the cache is below the limit.
The freshly initialized objects are put into the probation_lru, so we can always start
scanning at the end of protected_lru.
lru_get will always look in probation_lru first.
*/
void shrink_cache()
{
FWD_ITER(&protected_lru, lru_machine,
if ( above_cache_limit() && can_deallocate(link_iter)) {
shrink_cache_count++;
last_removed_cache = link_iter->pax.synode;
hash_out(&link_iter->pax); /* Remove from hash table */
link_into(link_out(&link_iter->lru_link), &probation_lru); /* Put in probation lru */
init_pax_machine(&link_iter->pax, link_iter, null_synode);
} else {
return;
}
);
}
```

但從我們的測試情況看,MySQL社區版並不能對cache大小進行有效限制,極容易出現遠超1GB的情況。這有兩方面原因導致:

首先,在對cache進行收縮時,不僅僅需要判斷cache大小是否超閾值above_cache_limit() ,還需要判斷can_deallocate()的返回值,該函數會統計MGR各個節點Xcom/Paxos已經執行並返回給MGR上層的最大消息序號msgno。只有序號小於msgno的消息才能被回收;

其次,我們再看看cache分配端的邏輯。

```
/*
Get a machine for (re)use.
The machines are statically allocated, and organized in two lists.
probation_lru is the free list.
protected_lru tracks the machines that are currently in the cache in
lest recently used order.
*/
static lru_machine *lru_get()
{
lru_machine * retval = NULL;
if (!link_empty(&probation_lru)) {
retval = (lru_machine * ) link_first(&probation_lru);
} else {
/* Find the first non-busy instance in the LRU */
FWD_ITER(&protected_lru, lru_machine,
if (!is_busy_machine(&link_iter->pax)) {
retval = link_iter;
/* Since this machine is in in the cache, we need to update
last_removed_cache */
last_removed_cache = retval->pax.synode;
break;
}
)
}
assert(retval && !is_busy_machine(&retval->pax));
return retval;
}
```

僅從lru_get()的函數說明就能發現,在從cache中獲取一個Paxos消息時(該消息後續會加入需要進行propose/prepare的具體內容),不論當前cache大小是否超標,都優先從空閑隊列probation_lru中獲取消息,僅當probation_lru為空時才會從當前的LRU隊列protected_lru中替換掉一個舊消息進行復用。

這就意味著,在大事務場景下,如果由於網路延時問題導致節點間出現較大的消息延時,那麼cache的大小就可能突破閾值限制並無法快速收縮。對於在雲主機這樣內存極其有限的環境中運行的MGR集羣來說,就更容易導致mysqld OOM。

**顯然,這可能是導致我們在測試時出現內存持續升高的一個原因。針對這個問題,在我們的MySQL版本中進行了優化,在lru_get()中先判斷當前cache大小是否已經超閾值,若已超,則跳過空閑隊列probation_lru隊列,直接從LRU隊列protected_lru中獲取消息。**

優化了cache大小問題後,我們進行了驗證性測試。遺憾的是,雖然cache大小被控制住了,但mysqld的內存還是增長了不少。顯然,除了paxos cache模塊外,還有其他模塊也在大量消耗內存。但這會是什麼模塊,什麼代碼邏輯的內存消耗呢?、

問題定位插曲

講到這裡,不得不說下debian系統低版本導致的內存泄露。

上圖是在debian7上測試得到的mysqld內存曲線。對比debian9,可以發現mysqld內存在測試完成後並沒有回落,且多次測試後,內存是不斷增加的。看起來,在debian7上,該NDC導入場景下MGR存在內存泄露,該問題的定位花費了不少時間,在此略過不表,最終定位發現這是由於debian7的XDR lib庫導致的內存泄露,bug鏈接如下:

[memory leak when failing to parse XDR strings/arrays]

XDR是External Data Representation的簡寫,XDR允許把數據包裝在獨立於介質的結構中使得數據可以在異構的計算機系統中傳輸。XDR使用軟體來完成變換,所以在不同的操作系統中可以靈活的運用。

MGR的paxos一致性協議的實現就用到了XDR庫,該問題在debian9上得到修復。鏈接如下:

`debian.pkgs.org/9/debia`

Paxos網路通信邏輯

這個問題的發現和解決,讓我們知道,測試過程中mysqld增漲的內存是跟Paxos的網路交互有關的。下面先簡單介紹下MGR中使用的Paxos變種mencius是如何實現多節點可寫的一致性協議的,詳細內容可閱讀:

「The king is dead, long live the king」: Our Paxos-based consensus

mencius/xcom協議簡介

在mencius實現中,每個節點都可以是master節點,可以對某個輪次的消息進行propose操作。下圖所示為3個節點組成的mencius組,節點的id分別為0、1和2。每個節點以round robin方式佔有對應輪次的消息。如id為0的節點可以對slot 0、3、6、9等輪次的paxos消息propose某個value值,而id為1的節點可以對slot 1、4、7、10等輪次進行propose。

對應到MGR中,就是每個組成MGR的MySQL節點都可以執行事務。事務在提交階段進入MGR,由mencius確定每個節點事務的執行/認證/回放順序。對應在下圖中,Server1(id 0)上的事務T2佔有了slot 0,Server3(id 1)上的事務T1佔有了slot 1,Server2(id 2)上的事務T3佔有了slot 2。

在這樣的機制下,如果每個節點的計算和IO資源相近,負載相似,那麼整個MGR集羣可以呈現最好的性能表現。但一般情況下,MGR集羣各節點的負載時不平衡的

比如只有MySQL節點1和2持續有事務執行和提交,節點0僅回放已經達成majority的事務Binlog日誌。這就意味著節點0僅有少數MGR內部產生的週期性地消息需要通過paxos達成majority,沒有事務提交操作,在這種情況下節點0大部分輪次需要使用noop消息(表示該消息為空,不攜帶真實數據)跳過。

在上圖中,節點0通過learn操作向集羣廣播noop消息,告訴大家跳過對應的slot 0和slot 3這兩個輪次。

mencius/xcom協議實現

在MGR中Paxos協議是通過C/C++下的協程實現的,Paxos各操作由同一個線程下的不同協程來完成。如PREPARE/PROPOSE操作由proposer_task來完成,READ和EXECUTE操作是有executor_task來完成,ACCEPT和LEARN操作由acceptor_learner_task來完成。

下文會提到多個Paxos操作類型,在此先介紹下:Paxos協議全流程分為PREPARE操作、PROPOSE操作、ACCEPT操作、LEARN操作、READ操作和EXECUTE操作,前三個是Paxos協議達成majority的基本流程,但對於mencius,正常情況下不需要PREPARE操作,因為每個節點有自己的輪次,可以直接進行PROPOSE操作,只有在需要發起PROPOSE重試操作或者其他節點想要通過noop佔用該消息輪次時才需要PREPARE操作;ACCEPT操作是其他節點對應PROPOSE操作的回復;LEARN操作是發起PROPOSE的節點向集羣廣播告訴大家某個消息輪次(序號msgno)已經達成majority,參與majority的節點可以執行EXECUTE操作了,沒有參與majority的節點的acceptor_learner_task可以執行READ操作來獲取了;某個節點在某個輪次沒有收到LEARN操作提示,會認為自己沒有參與到majority(可以是多種原因導致),executor_task會發送READ操作去其他節點獲取。

在具體的代碼實現中,如何知道某個消息輪次是要跳過呢?可以分為幾種情況:

第一種情況:如果節點0發現比slot 0更大的輪次,比如slot 1或slot 2已經完成了majority,到了等待EXECUTE的階段。那麼節點0會發送slot 0的noop;(這種情況經分析不會引發問題,在此簡單帶過)

第二種情況:由於每個節點都需要按序向MGR上層返回達成majority的消息,所以會等待slot 0達成majority。executor_task先嘗試READ操作從其他節點獲取slot 0的消息。如果重試多次後仍無法獲取,則會發起PREPARE操作。該操作主要是防止節點0網路延遲較大或者網路分區導致阻塞其他Paxos節點從而影響整體性能。

對於MGR單主模式,第二種情況更為普遍,executor_task獲取消息的核心代碼如下:

```
static void find_value(site_def const *site, unsigned int *wait, int n)
{
DBGOHK(FN; NDBG(*wait, d));

if(get_nodeno(site) == VOID_NODE_NO){
read_missing_values(n);
return;
}

switch (*wait) {
case 0:
case 1:
read_missing_values(n);
(*wait)++;
break;
case 2:
if (iamthegreatest(site))
propose_missing_values(n);
else
read_missing_values(n);
(*wait)++;
break;
case 3:
propose_missing_values(n);
break;
default:
break;
}
}
```

在find_value()函數中,*wait表示重試獲取某個消息的次數。0表示第一次。n表示同時獲取多少個消息。可以發現,頭兩次獲取某個消息時,調用的是read_miss_values(),第三次時,如果本節點id是所有節點中最小的(leader節點),則採用propose_missing_values()的方式。從第四次開始,所有節點都採用propose_missing_values()方式。從函數名就可以發現,前者通過發送READ操作以round robin方式從其他節點讀取對應消息。後者是通過向所有節點發送PROPOSE操作(需要先PREPARE)來嘗試跳過該輪次消息。

每次調用find_value()進行重試的時間間隔由函數wakeup_delay():

```
static double wakeup_delay(double old)
{
double retval = 0.0;
if (0.0 == old) {
double m = median_time();
if (m == 0.0 || m > 0.3)
m = 0.1;
retval = 0.1 + 5.0 * m + m * my_drand48();
} else {
retval = old * 1.4142136; /* Exponential backoff */
}
while (retval > 3.0)
retval /= 1.31415926;
/* DBGOUT(FN; NDBG(retval,d)); */
return retval;
}
```

wakeup_delay()會根據上一次重試前等待時間計算出本次需要等待多久才重試獲取該消息。下面是我們添加debug日誌列印出的函數輸入和返回值。

```
2019-05-15T17:44:47.118532+08:00 0 [Note] Plugin group_replication reported: wakeup_delay old 0.000000 new 0.102819
2019-05-15T17:44:47.220782+08:00 0 [Note] Plugin group_replication reported: wakeup_delay old 0.102819 new 0.145408
2019-05-15T17:44:47.366054+08:00 0 [Note] Plugin group_replication reported: wakeup_delay old 0.145408 new 0.205638
2019-05-15T17:44:47.571343+08:00 0 [Note] Plugin group_replication reported: wakeup_delay old 0.205638 new 0.290817
2019-05-15T17:44:47.861715+08:00 0 [Note] Plugin group_replication reported: wakeup_delay old 0.290817 new 0.411277
```

基於該函數,我們不同的初始值描繪出增長曲線,可以發現經過不同次數增長到3.0,達到3.0之後,在約2.3~3.0之前來回擺動。根據我們的觀察,wakeup_delay隨機產生的初始值基本上都略大於0.1。

executor_task實現的缺陷

通過上述代碼分析和實驗很容易發現,當前的MGR/Paxos實現代碼會在1s之內發起4~5次獲取指定msgno消息的嘗試。而從第3次開始,Paxos集羣的leader節點會開始進行該消息PREPARE操作(跳過本節點的輪次),從第4次開始,Paxos集羣中所有節點都會進行PREPARE操作。也就是在1s之內,executor_task就會從普通的READ操作變為激進的PREPARE操作。整個過程有至少有2點危害。

**網路和內存開銷成倍放大**

在過短的時間內進行多次READ操作,雖然READ本身網路開銷很小,但是所需讀取的輪次消息中的事務(或事務batch)可能很大,比如上述案例中的30MB,假設每次獲取10個,那麼1s之內最多可能會請求約1GB的數據量,也就是說其他節點回復該READ消息時需要通過網路傳輸這麼多數據量。在3節點MGR中,會有1個節點出現該行為,1GB數據量分攤給另外2個節點,每個節點也需要500MB的網路帶寬開銷。而網路開銷也意味著內存開銷,因為在其他節點回復READ操作時,需要拷貝一份數據,copy_app_data()即為數據拷貝函數:

```
/* Handle incoming read */
static void handle_read(site_def const *site, pax_machine *p,
linkage *reply_queue, pax_msg *pm) {
if (finished(p)) { /* We have learned a value */
teach_ignorant_node(site, p, pm, pm->synode, reply_queue);
}
}
static void teach_ignorant_node(site_def const *site, pax_machine *p,
pax_msg *pm, synode_no synode,
linkage *reply_queue) {
CREATE_REPLY(pm);
reply->synode = synode;
reply->proposal = p->learner.msg->proposal;
reply->msg_type = p->learner.msg->msg_type;
copy_app_data(&reply->a, p->learner.msg->a);
set_learn_type(reply);
/* set_unique_id(reply, p->learner.msg->unique_id); */
SEND_REPLY;
}
```

到這裡,還有個疑問:從下圖各節點的內存變化曲線看,只有Master/Primary節點內存升高。2個Slave節點內存均較為平穩。按照我們剛剛的分析,假定Slave2是不參與majority的節點,那麼Slave2會採用round robin的方式向Slave1和Primary節點發送READ操作。為何Slave1的內存不見增漲呢?

原因是Primary和Slave1的網路流量不均。對於一個消息,Primary需要在PROPOSE階段將完整的消息發送給所有節點,包括Primary節點自身;還需將達成majority的消息結果再廣播給每個節點(MGR在LEARN時做了優化,LEARN操作不會攜帶消息中的事務數據,如果接收到消息的節點未參與majority,就會發送READ操作)。而Slave1隻需要向Primary回復ACCEPT消息(不帶事務數據)。Primary相比Slave1有數倍的性能和網路開銷,更容易由於網路瓶頸導致網路消息包堆積,進而佔據過多的內存空間。

**過早PREPARE降低PROPOSE效率**

由於第4次開始為PREPARE,假設由於事務較大或網路性能較差,一個攜帶事務數據的Paxos消息從PROPOSE操作到LEARN操作的時間需超過1s,假設Primary節點上的事務A已經基於msgno1進行PROPOSE操作,消息還未成功發給任意Secondary/Slave節點,在目前的executor_task代碼實現下Secondary節點可能會發起PREPARE操作,由於PREPARE和後續空的PROPOSE操作消息都很小,傳輸效率高於事務A,那麼2個Secondary節點會先於事務A在msgno1輪次對noop消息達成majority,事務A不得不重新基於下一個輪次的msgno2來重新進行PROPOSE操作。這實際上減低了PROPOSE操作的效率,增大了網路開銷。對應代碼如下:

```
if (match_my_msg(ep->p->learner.msg, ep->client_msg->p)){
break;
} else {
G_MESSAGE("proposer_task retry new case 3(not match) msg (%lu, %d)",
ep->msgno.msgno, ep->msgno.node);
prepare_retry_count++;
GOTO(retry_new);
}
```

proposer_task在發起PROPOSE操作後,會等待該msgno的消息完成Paxos協議流程,然後判斷該消息攜帶的事務數據是否為其自己的。若不是,就需要就行重試。

proposer_task的實現邏輯

下面再看看消息proposer端的邏輯(抽取了核心部分):

```
if (threephase || ep->p->force_delivery) {
push_msg_3p(ep->site, ep->p, ep->prepare_msg, ep->msgno, normal);
} else {
push_msg_2p(ep->site, ep->p);
}
ep->start_push = task_now();
while (!finished(ep->p)) { /* Try to get a value accepted */
/* We will wake up periodically, and whenever a message arrives */
TIMED_TASK_WAIT(&ep->p->rv, ep->delay = wakeup_delay(ep->delay));

if (finished(ep->p)) break;
{
double now = task_now();
if ((ep->start_push + ep->delay) <= now) {
push_msg_3p(ep->site, ep->p, ep->prepare_msg, ep->msgno, normal);
ep->start_push = now;
}
}
}
```

可以發現,proposer_task在調用push_msg_2p()進行PROPOSE操作後,會轉入等待該消息完成。若未完成則進入睡眠狀態,喚醒後發現該消息還未完成,則會調用push_msg_3p()使用相同的msgno對消息進行PREPARE操作。循環如此,直到對應msgno完成。而睡眠喚醒後進行PREPARE重試的時間間隔也是由前述的wakeup_delay()返回值決定的,這同樣意味著,完成一個消息的Paxos流程較慢的話,會進行多次重試,如果每次都攜帶對應的事務數據,那麼也會導致嚴重的網路帶寬開銷。(不過針對這點,MGR的paxos實現本身已優化,在PREPARE操作時不攜帶事務數據。)

代碼優化和驗證

基於上述分析,我們找到了3個可能導致網路性能退化和內存增漲的問題。其中一個是由於Paxos cache大小未嚴格限制導致;另外兩個問題均出現在Paxos消息的獲取環節executor_task代碼邏輯上,分別是在短時間內頻繁發送READ操作,以及PREPARE消息中斷了proposer節點proposer_task正在執行的PROPOSE操作。

這些原因會導致網路情況急劇惡化,內存不受控得增漲,並出現前述提及的mysqld異常日誌:

週期性廣播消息紊亂:由於Primary節點存在較大網路延時,導致該節點的週期性廣播消息無法像其他節點那樣按時發送,此外,該廣播消息對應的輪次在發送的過程中也可能給其他節點使用noop搶佔掉。這也會導致消息無法按時發送。

無法獲取Paxos cache消息:Primary節點無法快速回復Secondary節點的READ操作,導致Secondary節點與Primary節點的Paxos消息延遲增大,最終由於Primary上對應的Paxos消息從cache中被替換導致Secondary無法通過READ操作獲取。

針對上述發現的問題,結合網易實際場景進行針對性優化

上圖所示為基於該優化進行驗證性測試結果,發現在事務執行性能沒有退化的情況下,內存增漲情況得到了有效遏制。

延伸閱讀:

  • MySQL Group Replication在網易使用和優化實踐
  • MySQL Group Replication衝突檢測機制再剖析
  • 程序員們的團建——戲說Paxos
  • MySQL RC級別下並發insert鎖超時問題 - 案例驗證
  • MySQL RC級別下並發insert鎖超時問題 - 源碼分析
  • MySQL RC級別下並發insert鎖超時問題 - 現象分析和解釋

推薦閱讀:

相關文章