測試了一下滾動重啟 tidb 導(dǎo)致事務(wù)失敗的情況谅辣,發(fā)現(xiàn)基本當(dāng)前在跑的事務(wù)基本全會失敗修赞,原因是當(dāng)前 tidb 停止的時候?qū)B接的處理還是比較粗暴。
如何測試
k apply -f tc.yaml
部署一個版本 v5.4.0-pre 的集群 (3 tidb, 1 tikv, 1 pd)桑阶,spec 如下:
apiVersion: pingcap.com/v1alpha1
kind: TidbCluster
metadata:
name: test
spec:
version: v5.4.0-pre
timezone: Asia/Kolkata
pvReclaimPolicy: Delete
enableDynamicConfiguration: true
configUpdateStrategy: RollingUpdate
discovery: {}
helper:
image: busybox:1.34.1
pd:
baseImage: pingcap/pd
maxFailoverCount: 0
enableDashboardInternalProxy: true
service:
type: ClusterIP
clusterIP: "None"
replicas: 1
# if storageClassName is not set, the default Storage Class of the Kubernetes cluster will be used
# storageClassName: local-storage
requests:
storage: "1Gi"
config: {}
tikv:
baseImage: pingcap/tikv
maxFailoverCount: 0
evictLeaderTimeout: 1m
replicas: 1
requests:
storage: "10Gi"
config:
storage:
reserve-space: "0MB"
tidb:
# just update it to trigger rolling update
annotations:
kk: v9
baseImage: pingcap/tidb
maxFailoverCount: 0
replicas: 3
service:
type: ClusterIP
storageVolumes:
- name: log
# storageClassName: ${storageClass}
storageSize: "10Gi"
mountPath: /var/tidb/log
config: |
graceful-wait-before-shutdown = 10
[log]
level = "debug"
[log.file]
filename = "/var/tidb/log/tidb.log"
寫一個簡單的測試程序柏副,并發(fā) n 個連接割择,不停跑事務(wù), 每個事務(wù)簡單跑兩個 replace 語句萎河。代碼在這里蕉饼。
k apply test.yaml
跑一個 pod 運行測試程序:
apiVersion: v1
kind: Pod
metadata:
name: test
spec:
containers:
- name: write
image: july2993/tk:latest
command:
- "/tk"
- "test"
- "--host"
- "test-tidb"
- "--max-connection"
- "1000"
隨便修改 tidb spec 的 annotations 觸發(fā)滾動重啟 tidb
...
tidb:
# just update it to trigger rolling update
annotations:
kk: v99
...
重啟完三個 tidb 后失敗事務(wù)個數(shù) 1275
...
[mysql] 2022/02/05 07:00:13 packets.go:123: closing bad idle connection: EOF
[mysql] 2022/02/05 07:00:13 packets.go:37: read tcp 10.244.0.30:56934->10.96.1.38:4000: read: connection reset by peer
[mysql] 2022/02/05 07:00:13 packets.go:123: closing bad idle connection: EOF
...
[2022/02/05 07:00:13.425 +00:00] [ERROR] [test.go:81] ["failed to run txn"] [error="failed to begin txn: invalid connection"]
[mysql] 2022/02/05 07:00:13 packets.go:37: read tcp 10.244.0.30:57754->10.96.1.38:4000: read: connection reset by peer
...
[2022/02/05 07:02:37.508 +00:00] [ERROR] [test.go:81] ["failed to run txn"] [error="failed to exec statement: invalid connection"] [stack="github.com/july2993/tk/cmd.glob..func1.2\n\t/go/src/github.com/july2993/tk/cmd/test.go:81"]
...
totalCount: 1030993, failCount: 1275, totalCountDiff: 2576, failCountDiff: 0
注意下這里有的是在 begin 的時候報錯昧港,有的是 exec statement 的時候報錯创肥。
為什么失敗
為了理解為什么會失敗值朋,我們需要了解下 tidb 是如何處理退出跟 go sql driver 。
tidb 如何處理退出
我們來看下處理 signal 退出的代碼code:
func main() {
//... omit some code
exited := make(chan struct{})
signal.SetupSignalHandler(func(graceful bool) {
svr.Close()
cleanup(svr, storage, dom, graceful)
cpuprofile.StopCPUProfiler()
close(exited)
})
topsql.SetupTopSQL()
terror.MustNil(svr.Run())
<-exited
syncLog()
}
這里 graceful
只有 signal 是 QUIT 才會是 true(不明原因為什么這樣), 我們可以先忽略只考慮 false 情況趾代。因為我們都是發(fā) SIGTERM 來停止 tidb, 一定時間后再直接 SIGKILL撒强。
svr.Close()
主要做如下事情 (code):
- 設(shè)置
inShutdownMode
為 true, 并等待s.cfg.GracefulWaitBeforeShutdown
糯俗,目的是為了先讓 LB 發(fā)現(xiàn)并且摘掉這個 tidb 睦擂。 - Close 掉全部
Listener
拒絕新連接。
cleanup()
主要看最后調(diào)用的 GracefulDwon()
:
// TryGracefulDown TryGracefulDown will try to gracefully close all connection first with timeout. if timeout, will close all connection directly.
func (s *Server) TryGracefulDown() {
ctx, cancel := context.WithTimeout(context.Background(), gracefulCloseConnectionsTimeout)
defer cancel()
done := make(chan struct{})
go func() {
s.GracefulDown(ctx, done)
}()
select {
case <-ctx.Done():
s.KillAllConnections()
case <-done:
return
}
}
func (s *Server) GracefulDown(ctx context.Context, done chan struct{}) {
logutil.Logger(ctx).Info("[server] graceful shutdown.")
metrics.ServerEventCounter.WithLabelValues(metrics.EventGracefulDown).Inc()
count := s.ConnectionCount()
for i := 0; count > 0; i++ {
s.kickIdleConnection()
count = s.ConnectionCount()
if count == 0 {
break
}
// Print information for every 30s.
if i%30 == 0 {
logutil.Logger(ctx).Info("graceful shutdown...", zap.Int("conn count", count))
}
ticker := time.After(time.Second)
select {
case <-ctx.Done():
return
case <-ticker:
}
}
close(done)
}
GracefulDown()
里 s.kickIdleConnection()
主要是掃一遍 s.clients
(維護的全部連接),如果當(dāng)前這個連接不處在一個事務(wù)中它就會 close 掉這個連接臼闻。但是注意它是每一秒檢查一遍,如果一個連接是不停的跑事務(wù)它很可能一直不會 close, 到最后超過 gracefulCloseConnectionsTimeout
(15s) 后不管連接當(dāng)前狀態(tài)直接 close 掉連接述呐。前文說的 exec statement 的時候報錯的就都是等到這里直接 close 的。
go sql driver
這里我們使用 driver 是 https://github.com/go-sql-driver/mysql, driver 實現(xiàn)并不自己管理連接池而是 go 的database/sql package 來管理的思犁,driver 實現(xiàn) database/sql/driver 下的一些接口激蹲。實現(xiàn)通過返回 driver.ErrBadConn
來告訴 sql package 這個連接狀態(tài) invalid(比如 server 端 close 掉了連接)江掩, 你需要使用新的連接重試乘瓤。
go-sql-driver/mysql 對連接的檢查邏輯主要在 conncheck.go, 參考 pr924衙傀。做的主要是當(dāng)一個連接在連接池被拿來用的時候第一次執(zhí)行語句的時候先非阻塞讀下這個連接萨咕,如果讀不到任何數(shù)據(jù)并且 err 是 syscall.EAGAIN 或者 syscall.EWOULDBLOCK 這個連接就是正常的,否則返回 ErrBadConn蓄喇。我們重啟的時候部分跑 begin 失敗的事務(wù)就是因為 client 側(cè)還沒感知到我們 server 要 close 或者已經(jīng) close 掉了這個連接交掏,然后拿著連接跑 "START TRANSACTION" 語句的時候失敗了。
原因總結(jié)
可以看到 tidb 嘗試在事務(wù)間 close 掉連接(或者說在連接空閑的時候 close 掉連接)钱骂。一類失敗是 server 端 close 連接跟 client 檢查 連接狀態(tài)之間的 race 導(dǎo)致的挪鹏。一類失敗是 tidb 嘗試 close 掉連接超時后直接 close 全部連接導(dǎo)致的(exec statement 失敗的部分都是)讨盒。
優(yōu)化
每個連接都會有個 gorouting 跑 func (cc *clientConn) Run(ctx context.Context)。 Run 函數(shù)做的注意是不停讀一個 packet(這里指 mysql 協(xié)議的 packet)禀苦,然后處理這個 packet遂鹊。這里我們可以改成讓這個 clientConn 的 Run 自己發(fā)現(xiàn)當(dāng)前要 shutdown 然后自己選擇在合適的時候(就是跑完當(dāng)前事務(wù))close 掉連接,而不是靠外部定時檢查慧邮。這樣我們可以做到每個連接跑完當(dāng)前事務(wù)后馬上 close, 不會出現(xiàn)超時后強制 close 的情況舟陆。但是 begin 失敗的情況,因為這里有 race脓匿,我們 close 連接的時候 client 甚至可能已經(jīng)用了這個連接發(fā)送了開啟事務(wù)的語句并且發(fā)成功了宦赠,這部分我們是沒辦法的米母。
修改后重新測試(代碼在這里) failCount: 642铁瞒,失敗只有之前的不到一半桅滋。為了測試當(dāng)不是很 active 的跑事務(wù)的時候錯誤情況,我們改下測試程序讓每個 goroutin 在 DB 里拿連接跑完一個事務(wù)后 sleep 一秒芍碧,后重新測試:
version | failCount |
---|---|
v5.4.0-pre | 57 |
優(yōu)化后版本 | 0 |
可以看到優(yōu)化后的版本沒有任何失敽爬(不保證一定不會有失敗)踪危。
一個進一步的可能優(yōu)化是 server 端處理 commit 語句之前就先 close tcp read 側(cè)(ref shutdown TCPConn.CloseREAD), 如果 driver 的 connCheck
實現(xiàn)再檢查下這個連接是否 close read 了那么理論上對于我們的測試程序是可以做到在滾動重啟 tidb 的時候不會有任何失敗的贞远。
總結(jié)
優(yōu)化后的 tidb 可以減少重啟 tidb 導(dǎo)致的 client 端失敗笨忌,在事務(wù)間 close 掉連接,不會出現(xiàn)超過 gracefulCloseConnectionsTimeout
后直接 close 連接的情況(在事務(wù)本身跑的時間不超過 gracefulCloseConnectionsTimeout 的前提下)杂曲。