跳到正文
DEV Community· Hamed Yousefi·· 4 小時前AI 評分57

etcd/raft 故障切換中位時間由 1.25 s 降至 343 ms,經 2,000 次模擬領導者崩潰測試

Cutting etcd/raft failover from 1.25 s to 343 ms, checked against 2,000 simulated leader crashes

AI 導讀

Hamed Yousefi 使用 faultline 測試 etcd/raft,將 ElectionTick 設為 3 後,2,000 次模擬崩潰的故障切換中位時間為 343 ms,低於預設設定的 1.249 s。

正文

When the leader of a three-node etcd/raft cluster dies, the other two servers wait between 1 and 1.9 seconds before one of them holds an election. That's with a tick every 100 ms and the settings from etcd/raft's README: ElectionTick 10 and HeartbeatTick 1. Until a new leader is in place, no write commits. I wanted that gap under a second.

Lowering ElectionTick is a one-line change. What I didn't know was what it does once servers crash and links drop. On a test cluster I could kill leaders and time the gap, but every attempt sees different timing. When one is slow, there's no way to run it again and see why.

So I tried it with faultline, the deterministic simulation testing tool for Go I've been building. Version 0.1.1 came out today. A faultline test runs a distributed system in virtual time on a simulated network and disk. Every random choice in the simulation comes from a seed, so a run replays exactly.

faultline includes a harness for etcd/raft v3.7.0. It runs a cluster of RawNodes with a write-ahead log on the simulated disk and clients that write and read keys, and checks raft's safety properties after every event. It tests the raft library with your settings. The key-value store, the log and the transport on top are the harness's own, not your service's.

Is a shorter timeout still safe?

I started with safety. The first test runs 500 seeds with elections after 3 ticks instead of 10. Each seed runs 60 seconds of virtual time under partitions, an isolated leader, cut and lossy links, crashes, pauses, failed disk syncs and clock drift. The last 10 seconds are quiet, and the cluster must make progress again.

func TestFastElectionsStaySafe(t *testing.T) {
    cfg := etcdraft.DefaultConfig()
    cfg.ElectionTick = 3 // elections after 3 to 5 ticks of 100 ms, instead of 10 to 19
    etcdraft.Test(t, cfg, faultline.Options{Seeds: 500})
}
$ go test -run '^TestFastElectionsStaySafe$' .
ok      example.com/failover    37.110s

No seed broke a safety check or left the cluster stuck. That makes the change safe in these runs. It says nothing yet about how fast failover got.

How long does failover take?

Then I timed it. The next test crashes whichever server leads at 5 seconds, with no other faults, and checks the time until another server becomes leader. etcdraft.Setup builds the cluster inside faultline.Run, and LeaderChanges lists every change of leadership in the run:

// failover crashes whichever server leads at 5s. check gets the time until the next leader and
// the number of elections it took.
func failover(t *testing.T, cfg etcdraft.Config, seeds int, check func(time.Duration, uint64) error) {
    cfg.Faults = etcdraft.FaultsNone // no faults except the crash below
    cfg.Duration = 20 * time.Second
    opts := faultline.Options{Seeds: seeds, Duration: cfg.Duration, Net: etcdraft.NetConfig()}
    faultline.Run(t, opts, func(w *faultline.World) {
        c := etcdraft.Setup(w, cfg)
        crash := kernel.Time(5 * time.Second)
        w.Plan(fault.Script(fault.Event{At: crash, Kind: fault.KindCrash, Role: "leader"}))
        w.Final("new leader after the crash", func() error {
            var oldTerm uint64
            for _, ch := range c.LeaderChanges() {
                if ch.State != raft.StateLeader {
                    continue
                }
                if ch.At <= crash {
                    oldTerm = ch.Term
                    continue
                }
                return check(time.Duration(ch.At-crash), ch.Term-oldTerm)
            }
            return errors.New("no new leader after the crash")
        })
    })
}

func TestFailoverUnderOneSecond(t *testing.T) {
    cfg := etcdraft.DefaultConfig()
    cfg.ElectionTick = 3
    failover(t, cfg, 200, func(d time.Duration, _ uint64) error {
        if d > time.Second {
            return fmt.Errorf("new leader %v after the crash", d)
        }
        return nil
    })
}

It failed on the 45th seed:

$ go test -run '^TestFailoverUnderOneSecond$' .
--- FAIL: TestFailoverUnderOneSecond (1.14s)
    failover_test.go:30: faultline: running 200 seeds from base 0xf41bc040834225b9 (test name)
    --- FAIL: TestFailoverUnderOneSecond/seed=0xe05bdf977f330e42 (0.19s)
        faultline: final check "new leader after the crash" failed after the run at t=20.000000000s
          new leader 1.783042452s after the crash
        replay:    FAULTLINE_SEED=0xe05bdf977f330e42 go test -v -run '^TestFailoverUnderOneSecond$' example.com/failover
        artifacts: /tmp/faultline-501/example.com_failover/TestFailoverUnderOneSecond/e05bdf977f330e42/
    failover_test.go:30: faultline: stopping after failing seed 0xe05bdf977f330e42; 155 of 200 seeds not run (set Options.KeepGoing to run all)
FAIL
FAIL    example.com/failover    1.678s
FAIL

Reading the slow seed

This time I could run the slow one again as often as I liked. The replay command runs the identical run, with the same events at the same virtual times. The failing seed also left an artifact directory, and its text timeline shows what happened after the crash:

grep -E "fault.crash|became (candidate|leader)" timeline.txt
    254  0.220702122s n3#1    etcdraft.raft_log    3 became candidate at term 2 level=info <-253
    300  0.225595569s n3#1    etcdraft.raft_log    3 became leader at term 2 level=info <-299
  18833  5.000000000s n3#1    fault.crash          crash n3 id=1 source=script node=n3 effect=applied <-18832
  18905  5.473915956s n2#1    etcdraft.raft_log    2 became candidate at term 3 level=info <-18904
  18916  5.474188777s n1#1    etcdraft.raft_log    1 became candidate at term 3 level=info <-18915
  19083  5.873566634s n1#1    etcdraft.raft_log    1 became candidate at term 4 level=info <-19082
  19094  5.874618666s n2#1    etcdraft.raft_log    2 became candidate at term 4 level=info <-19093
  19271  6.374222166s n1#1    etcdraft.raft_log    1 became candidate at term 5 level=info <-19270
  19290  6.375359195s n2#1    etcdraft.raft_log    2 became candidate at term 5 level=info <-19289
  19457  6.774700502s n1#1    etcdraft.raft_log    1 became candidate at term 6 level=info <-19456
  19505  6.783042452s n1#1    etcdraft.raft_log    1 became leader at term 6 level=info <-19504

n1 and n2 became candidates within about a millisecond of each other, three rounds in a row. Each voted for itself, so neither got the second vote it needed, and both waited out another timeout. In the fourth round only n1 ran, and it won.

etcd/raft picks each server's timeout in whole ticks: electionTimeout + rand.Intn(electionTimeout), drawn from crypto/rand, which faultline seeds for every run. With ElectionTick 3, a server waits 3, 4 or 5 ticks. The harness starts each server's ticks at a random point within the first interval, as real servers that started at different times would. In this seed, n1's and n2's ticks landed less than half a millisecond apart, and they kept drawing the same number of ticks.

How often does it happen?

One slow seed doesn't give a rate. The next test runs 2,000 crashes per setting and records the time to the next leader and the number of election rounds it took. More than one round means at least one round elected nobody, as in the seed above:

func TestFailoverDistribution(t *testing.T) {
    for _, c := range []struct {
        name  string
        tick  time.Duration
        ticks int
    }{
        {"10 ticks of 100ms", 100 * time.Millisecond, 10},
        {"3 ticks of 100ms", 100 * time.Millisecond, 3},
        {"10 ticks of 30ms", 30 * time.Millisecond, 10},
    } {
        var took []time.Duration
        var repeated, slow int
        t.Run(c.name, func(t *testing.T) {
            cfg := etcdraft.DefaultConfig()
            cfg.TickInterval, cfg.ElectionTick = c.tick, c.ticks
            failover(t, cfg, 2000, func(d time.Duration, elections uint64) error {
                took = append(took, d)
                if elections > 1 {
                    repeated++ // at least one election round elected nobody
                }
                if d > time.Second {
                    slow++
                }
                return nil
            })
        })
        slices.Sort(took)
        n := len(took)
        ms := func(d time.Duration) time.Duration { return d.Round(time.Millisecond) }
        t.Logf("%-17s median %v, p99 %v, max %v; repeated elections in %d, over 1s in %d of %d",
            c.name, ms(took[n/2]), ms(took[n*99/100]), ms(took[n-1]), repeated, slow, n)
    }
}
$ go test -v -run '^TestFailoverDistribution$' . | grep repeated
    failover_test.go:91: 10 ticks of 100ms median 1.249s, p99 1.893s, max 3.155s; repeated elections in 16, over 1s in 1797 of 2000
    failover_test.go:91: 3 ticks of 100ms  median 343ms, p99 622ms, max 1.177s; repeated elections in 27, over 1s in 1 of 2000
    failover_test.go:91: 10 ticks of 30ms  median 381ms, p99 814ms, max 1.418s; repeated elections in 32, over 1s in 7 of 2000

With 3 ticks, half the failovers finished within 343 ms and 99% within 622 ms, against 1.249 s and 1.893 s with the default. One failover in 2,000 took more than a second. About 1.4% needed more than one round.

Fewer ticks or shorter ticks?

The third line is the other way to reach a 300 ms timeout: keep 10 ticks and make each one 30 ms. I expected it to repeat elections less often, because a server then picks from 10 timeouts instead of 3. It didn't. Its median was close, and its tail was worse: 7 failovers over a second, and a p99 of 814 ms.

Three more settings, 2,000 crashes each, point at the reason. Settings whose shortest timeout is 300 ms repeated an election in 24 to 32 of 2,000 failovers, whether they used 3, 10 or 20 ticks. Settings that start at 600 ms or later repeated one in 14 to 20. So the timeout in milliseconds, not the number of ticks, seems to set how often two servers start an election together.

30 ms ticks also cost something that 3 ticks don't. With HeartbeatTick 1, the leader sends a heartbeat every tick, so 30 ms ticks send heartbeats more than three times as often. The same settings passed 500 seeds of the full fault mix, like the 3-tick version:

func TestShortTicksStaySafe(t *testing.T) {
    cfg := etcdraft.DefaultConfig()
    cfg.TickInterval = 30 * time.Millisecond
    etcdraft.Test(t, cfg, faultline.Options{Seeds: 500})
}
$ go test -run '^TestShortTicksStaySafe$' .
ok      example.com/failover    43.569s

What a shorter timeout costs

A shorter timeout reacts faster to every missed heartbeat, not only to a dead leader. I counted the elections in 200 runs of 60 seconds for each setting. With no faults, all three settings held exactly one election per run, the first. Under the default faults, the default setting held 8.95 per run, and the two 300 ms settings 12.7 and 12.4. Each election is a short window with no leader. Some of the extra ones are faster reactions to real failures; others are elections a longer timeout would have ridden out, and these runs don't separate the two.

etcd/raft's documentation suggests ElectionTick = 10 * HeartbeatTick "to avoid unnecessary leader switching". In these runs, the 3:1 setting and the 10:1 setting held the same number of extra elections, so the timeout in milliseconds mattered and the ratio didn't. On a network that loses messages in bursts, the ratio may still matter. The harness doesn't model that kind of loss.

What this doesn't tell you

The harness runs etcd/raft with its own state machine, log and transport. Your service's code is not in the run. To test it, you port it to faultline's node API, and the harness is one worked example of such a port.

The network is a model with 1 ms of latency and up to 2 ms of jitter. One message in 100 is delayed by up to 50 ms more, one in 1,000 is lost, and one in 1,000 is duplicated. A real network with longer stalls, or a process with long garbage collection pauses, needs more headroom than these runs show. Inside the simulation a callback takes no time, so CPU load doesn't slow the cluster down.

Try it

Every test above runs with go test, against the released modules. All four took under four minutes on my laptop, an Apple M4 Max with Go 1.27.1:

go get github.com/hmdsefi/faultline/harness/etcdraft@v0.1.1

I first ran these tests on v0.1.0, and v0.1.1 gives the same numbers. v0.1.1 also adds faultline.RunSeeds, which keeps a failing seed like the slow one above as a regression test.

Test etcd/raft with the harness covers the rest of the harness: cluster sizes, fault presets, snapshots and membership changes. The documentation starts with how an AI coding agent runs the same loop, from a failing seed to a pinned regression test, and the repository is on GitHub.

來源:DEV Community · dev.to