記一次etcd全局鎖使用不當導致的事故

来源:https://www.cnblogs.com/huageyiyangdewo/archive/2023/07/01/17519746.html
-Advertisement-
Play Games

### 1、背景介紹 前兩天,現場的同事使用開發的程式測試時,發現日誌中報`etcdserver: mvcc: database space exceeded`,導致 etcd 無法連接。很奇怪,我們開發的程式只用到了 etcd 做程式的主備,並沒有往 etcd 中寫入大量的數據,為什麼會造成 et ...


1、背景介紹

前兩天,現場的同事使用開發的程式測試時,發現日誌中報etcdserver: mvcc: database space exceeded,導致 etcd 無法連接。很奇怪,我們開發的程式只用到了 etcd 做程式的主備,並沒有往 etcd 中寫入大量的數據,為什麼會造成 etcd 空間不足呢?趕緊叫現場的同事查了下 etcd 存儲數據的目錄以及 etcd 的狀態,看看是什麼情況。

查看 etcd 狀態:

./etcdctl endpoint status --write-out=table --endpoints=localhost:12380

看到這裡就很奇怪了,為什麼 RAFT APPLYEND INDEX 會這麼大呢?這完全是不正常的。

想到程式中有主備,程式啟動時,會去 etcd 中 trylock 相應的鎖,獲取不到時,則會定期去 trylock,會不會是這裡的備節點 定期去 trylock 導致 RAFT APPLYEND INDEX 持續增長從而導致 etcd 空間不足呢?

後面測試了一下,不啟動備節點時,RAFT APPLYEND INDEX 是不會增大的。那麼問題的原因找到了,問題也就比較好解決。

雖然 etcd 提供了 compact 的能力,但是對於我們這個現象,是治標不治本的,所以最好還是從源頭解決問題比較好。當然也可以使用 compact 來壓縮 etcd 的 歷史數據,但是需要註意的是 compact 時,etcd 的性能是會收到影響的。

2、場景復現

etcd client 版本

go.etcd.io/etcd/client/v3 v3.5.5

etcd server 版本

etcd-v3.5.8-linux-amd64

模擬代碼如下:

package main

import (
	"context"
	"fmt"
	clientv3 "go.etcd.io/etcd/client/v3"
	"go.etcd.io/etcd/client/v3/concurrency"
	"time"
)

var TTL = 5
var lockName = "/TEST/LOCKER"

func main() {
	config := clientv3.Config{
		Endpoints:   []string{"192.168.91.66:12379"},
		DialTimeout: 5 * time.Second,
	}
	// 建立連接
	client, err := clientv3.New(config)
	if err != nil {
		fmt.Println(err)
		return
	}

	session, err := concurrency.NewSession(client, concurrency.WithTTL(TTL))
	if err != nil {
		fmt.Println("concurrency.NewSession failed, err:", err)
		return
	}
	gMutex := concurrency.NewMutex(session, lockName)

	ctx, _ := context.WithCancel(context.Background())

	if err = gMutex.TryLock(ctx); err == nil {
		fmt.Println("gMutex.TryLock success")
	} else {
		if err = watchLock(gMutex, ctx); err != nil {
			fmt.Println("get etcd global key failed")
			return
		}
	}

	// 啟動成功,做具體的業務邏輯處理
	fmt.Println("todo ..............")
	select {}

}

func watchLock(gMutex *concurrency.Mutex, ctx context.Context) (err error) {
	ticker := time.NewTicker(time.Second * time.Duration(TTL))

	for {
		if err = gMutex.TryLock(ctx); err == nil {
			// 獲取到鎖
			return nil
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-ticker.C:
			continue
		}
	}
}

將上述代碼編譯成可執行文件 main.exe、main1.exe 後,先後執行上面兩個可執行文件,然後通過下麵的命令查看 etcd 中的 RAFT APPLYEND INDEX ,會發現,RAFT APPLYEND INDEX 每隔五秒鐘就會增長,長時間運行就會出現 etcdserver: mvcc: database space exceeded

3、如何解決

上面我們已經復現了RAFT APPLYEND INDEX,其實解決起來也比較簡單,主要思路就是不要在 for 迴圈中 使用 trylock 方法。具體代碼如下:

package main

import (
	"context"
	"fmt"
	clientv3 "go.etcd.io/etcd/client/v3"
	"go.etcd.io/etcd/client/v3/concurrency"
	"time"
)

var TTL = 5
var lockName = "/TEST/LOCKER"

func main() {
	config := clientv3.Config{
		Endpoints:   []string{"192.168.91.66:12379"},
		DialTimeout: 5 * time.Second,
	}
	// 建立連接
	client, err := clientv3.New(config)
	if err != nil {
		fmt.Println(err)
		return
	}

	session, err := concurrency.NewSession(client, concurrency.WithTTL(TTL))
	if err != nil {
		fmt.Println("concurrency.NewSession failed, err:", err)
		return
	}
	gMutex := concurrency.NewMutex(session, lockName)

	ctx, _ := context.WithCancel(context.Background())

	if err = gMutex.TryLock(ctx); err == nil {
		fmt.Println("gMutex.TryLock success")
	} else {
		if err = watchLock(client, gMutex, ctx); err != nil {
			fmt.Println("get etcd global key failed")
			return
		}
	}

	// 啟動成功,做具體的業務邏輯處理
	fmt.Println("todo ..............")
	select {}

}

func watchLock(client *clientv3.Client, gMutex *concurrency.Mutex, ctx context.Context) (err error) {

	watchCh := client.Watch(ctx, lockName, clientv3.WithPrefix())

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-watchCh:
			if err = gMutex.TryLock(ctx); err == nil {
				// 獲取到鎖
				return nil
			}
		}
	}
}

將上述代碼編譯成可執行文件 main.exe、main1.exe 後,先後執行上面兩個可執行文件,然後通過下麵的命令查看 etcd 中的 RAFT APPLYEND INDEX ,不會出現RAFT APPLYEND INDEX 持續增長的現象,也就是從源頭解決了問題。

4、TryLock 源碼分析

以下是自己的理解,如果有不對的地方,請不吝賜教,十分感謝

那下麵一起看看 TryLock 方法裡面做了什麼操作,會導致 RAFT APPLYEND INDEX 持續增長呢。

TryLock 方法源碼如下:

func (m *Mutex) TryLock(ctx context.Context) error {
	resp, err := m.tryAcquire(ctx)
	if err != nil {
		return err
	}
	// if no key on prefix / the minimum rev is key, already hold the lock
	ownerKey := resp.Responses[1].GetResponseRange().Kvs
	if len(ownerKey) == 0 || ownerKey[0].CreateRevision == m.myRev {
		m.hdr = resp.Header
		return nil
	}
	client := m.s.Client()
	// Cannot lock, so delete the key
    // 這裡的 client.Delete 會走到 raft 模塊,從而使 etcd 的 raft applyed index 增加 1
	if _, err := client.Delete(ctx, m.myKey); err != nil {
		return err
	}
	m.myKey = "\x00"
	m.myRev = -1
	return ErrLocked
}

tryAcquire 方法源碼如下:

// 下麵主要是使用到了 etcd 中的事務,
func (m *Mutex) tryAcquire(ctx context.Context) (*v3.TxnResponse, error) {
	s := m.s
	client := m.s.Client()
	
    // m.myKey = /TEST/LOCKER/326989110b4e9304
	m.myKey = fmt.Sprintf("%s%x", m.pfx, s.Lease())
    // 這裡就是定義一個判斷語句,創建 myKey 時的版本號是否 等於 0
	cmp := v3.Compare(v3.CreateRevision(m.myKey), "=", 0)
	// put self in lock waiters via myKey; oldest waiter holds lock
    // 往 etcd 中寫入 myKey
	put := v3.OpPut(m.myKey, "", v3.WithLease(s.Lease()))
	// reuse key in case this session already holds the lock
    // 查詢 myKey
	get := v3.OpGet(m.myKey)
	// fetch current holder to complete uncontended path with only one RPC
	getOwner := v3.OpGet(m.pfx, v3.WithFirstCreate()...)
    // 這裡是重點,判斷 cmp 中的條件是否成立,成立則執行 Then 中的語句,否則執行  Else 中的語句
    // 這裡的語句肯定是成功的,因為我們測試的環境是執行兩個不同的 session
    // 簡單的可以理解為兩個不同的程式,實際上是 兩個不同的會話就會不同
    // 所以我們這裡的場景是 會執行 v3.OpPut 操作。所以這裡會增加一次 revision
    // 即 etcd 的 raft applyed index 會增加 1
    resp, err := client.Txn(ctx).If(cmp).Then(put, getOwner).Else(get, getOwner).Commit()
	if err != nil {
		return nil, err
	}
	m.myRev = resp.Header.Revision
	if !resp.Succeeded {
		m.myRev = resp.Responses[0].GetResponseRange().Kvs[0].CreateRevision
	}
	return resp, nil
}

下麵這張圖是 debug 時,先啟動一個可執行文件,然後使用 debug 方式啟動的程式,程式執行完 tryAcquire 方法後,截取的一張圖,這也作證了上面的分析。304 這個 key 是之前啟動程式就存在的 key,下麵 30f 的 key 是 debug 期間生成的 key。

大家如果有不清楚的地方,親自去調試下,看看代碼,就會明白上面說的內容了。

5、思考

其實,這並不是難以考慮到的問題,代碼中出現這個問題,主要是自己對 etcd 的瞭解程度不夠,不清楚 TryLock 的原理,以為像簡單的查詢Get那樣,不會導致 revision 的增長,但實際上並不是這樣。而是生產中出現了問題才去看為什麼會這樣,然後再去解決問題,這是一種不太好的方式,希望以後在編碼的時候,儘量多考慮考慮,減少問題出現。

想起來前幾天看到一篇問題,也是 for 迴圈中的出現的問題,原文鏈接,感謝可以去看看 Go坑:time.After可能導致的記憶體泄露問題分析


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • urllib+BeautifulSoup爬取並解析2345天氣王歷史天氣數據 網址:[東城歷史天氣查詢_歷史天氣預報查詢_2345天氣預報](https://tianqi.2345.com/wea_history/71445.htm) ![image-20230702161423470](https ...
  • # 數列分段 Section II ## 題目描述 對於給定的一個長度為N的正整數數列 $A_{1\sim N}$,現要將其分成 $M$($M\leq N$)段,並要求每段連續,且每段和的最大值最小。 關於最大值最小: 例如一數列 $4\ 2\ 4\ 5\ 1$ 要分成 $3$ 段。 將其如下分段: ...
  • 本文通過閱讀Spring源碼,分析Bean實例化流程。 # Bean實例化入口 上一篇文章已經介紹,Bean實例化入口在AbstractApplicationContext類的finishBeanFactoryInitialization方法: ```java protected void fini ...
  • # 1、Java常用插件實現方案 ## 1.2、serviceloader方式 serviceloader是java提供的spi模式的實現。按照介面開發實現類,而後配置,java通過ServiceLoader來實現統一介面不同實現的依次調用。而java中最經典的serviceloader的使用就是J ...
  • **原文鏈接:** [Go 語言 context 都能做什麼?](https://mp.weixin.qq.com/s/7IliODEUt3JpEuzL8K_sOg) 很多 Go 項目的源碼,在讀的過程中會發現一個很常見的參數 `ctx`,而且基本都是作為函數的第一個參數。 為什麼要這麼寫呢?這個參 ...
  • POM( Project Object Model,項目對象模型 ) 是 Maven 工程的基本工作單元,它是一個 XML 文件,包含了項目的基本信息,用於描述項目如何構建,聲明項目依賴等等。執行任務或目標時,Maven 會在當前目錄中查找並讀取 POM,獲取所需的配置信息,然後執行目標。 1、基本 ...
  • # HttpServletResponse對象 ## 基本介紹 ​ Web伺服器收到客戶端的http請求,會針對每次請求,分別創建一個用於**代表請求**的 request對象 和**代表響應**的 response對象。 ​ request 和 response對象 代表請求和響應:**獲取客戶瑞 ...
  • 不知不覺,《C++面試八股文》已經更新30篇了,這是我第一次寫技術博客,由於個人能力有限,出現了不少紕漏,在此向各位讀者小伙伴們致歉。 為了不誤導更多的小伙伴,以後會不定期的出勘誤文章,請各位小伙伴留意。 在《[C++面試八股文:C++中,設計一個類要註意哪些東西?](https://zhuanla ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...