Day 17 / 共 20 天 · 第 4 周 高级特性与实战

Leader 选举与共享数据

昨天(Day 15)用 Redis 做跨实例共享;今天讲另一种不依赖外部服务的跨 VM 共享——proxy-wasm 自带的 SharedData。有些活只该"一个实例干"(定时上报、清理过期数据),不能每个 VM 都干一遍。今天看 wasm-go 怎么靠 SharedData + CAS 选出一个 Leader。这是分布式系统的经典问题。

📍 你在整门课的位置(wasm-go 20 天 · 第 4 周 高级特性与实战)
日志 D16 Leader 选举 D17 Token 解析 D18 性能测试 D19 构建部署 D20
L01

为什么要选 Leader

💡 本质:选 Leader = 一群人抢一块"值班牌" 想象一屋子平等的员工,桌上有唯一一块"今日值班"牌子。谁先抢到就负责当天的独占活(打扫、上报),别人待命。难点是"同时伸手抢时怎么保证只有一个人拿到"——靠 CAS(拿牌时报一下牌子的编号,编号没变才算你拿到)。而且这牌子要定时打卡续期,值班的人跑路了(VM 崩了),牌子过期,别人就能重新抢。全程没有"班长"来分配,纯靠这块牌子自组织。
"这活只能一个人干" 网关有很多实例、每个实例很多 VM。有些任务重复干会出问题:比如"每分钟向监控系统上报一次汇总"——如果每个 VM 都报,数据翻了 N 倍;"每小时清理一次过期缓存"——多个一起清会冲突。解法:选出一个 Leader,只有它干这些独占任务,其余待命。这就是"Leader 选举"。难点在于:没有 ZooKeeper/etcd 这种协调服务,怎么在一堆平等的 VM 里选出唯一一个?
L02

SharedData 与 CAS

答案是 proxy-wasm 的 SharedData——一块跨 VM 共享的键值存储(同一 Envoy 进程内所有 VM 可见,弥补 Day 03 "全局变量各 VM 独立"的限制)。关键是它支持 CAS(Compare-And-Swap)

data, cas, err := proxywasm.GetSharedData(key)   // 读值 + 版本号 cas
proxywasm.SetSharedData(key, newValue, cas)       // 只有 cas 匹配才写成功
CAS = 原子的"没人改过我才写" 读的时候拿到一个版本号 cas;写的时候带上它——如果期间别人改过(版本变了),你的写入失败。这保证了"多个 VM 同时抢,只有一个能成功写入"。回想 Day 07 的乐观锁、Day 15 的 Redis SetNX——都是同一个思想:用原子操作在竞争中选出唯一赢家。SharedData + CAS 是 wasm-go 不依赖外部服务就能做 Leader 选举的基础。(Day 09 的规则备份也存在 SharedData 里。)
两个 VM 同时抢租约,CAS 保证只有一个赢 VM-A 读: cas=7"我要抢" VM-B 读: cas=7"我也要抢" SharedData 租约当前版本 cas=7 A 先写(带 cas=7)✔成功 → 版本变 8 → A 当选 B 再写(带 cas=7)✘版本已是 8≠7 → 失败 → 认输
图注:两个 VM 都读到 cas=7 后抢写。A 先写成功、版本变 8;B 带着旧的 cas=7 再写,版本对不上被拒——唯一赢家 = A。
L03

Lease 租约

plugin_wrapper.go:563-566 定义租约:

type Lease struct {
    VMID      string `json:"vmID"`      // 谁持有租约
    Timestamp int64  `json:"timestamp"` // 何时续的
}
为什么用"租约"而不是"永久任命"? 如果直接选一个 Leader 永久任命,万一它挂了(VM 崩溃/实例下线),这活就永远没人干了。租约机制:Leader 要定期"续租"(更新 Timestamp)证明自己还活着;租约过期(比如 60 秒没续)就视为 Leader 挂了,其他 VM 可以抢。好比"值班牌"要定时打卡,不打卡就换人。这样 Leader 挂了能自动故障转移。VMID(Day 03 的 uuid)标识是哪个 VM 持有。
L04

抢/续租实现

tryAcquireOrRenewLeaseplugin_wrapper.go:576-608):

data, cas, err := proxywasm.GetSharedData(ctx.leaderLeaseKey())
if 没有租约 { return ctx.setLease(now, cas) }   // 没人占,我占
var lease Lease; json.Unmarshal(data, &lease)
// 如果租约是我自己的 → 直接续;如果过期超 60s → 抢过来
if lease.VMID == ctx.vm.vmID || now-lease.Timestamp > 60 {
    lease.VMID = ctx.vm.vmID; lease.Timestamp = now
    return ctx.setLease(now, cas)                // CAS 写入,成功=当选/续租成功
}
return false   // 别人的有效租约,我不是 Leader
读法:三种情况:①没租约→抢;②租约是我的→续;③租约是别人的但过期>60s→抢。都通过 setLease(CAS 写入)落实——多个 VM 同时抢时,CAS 保证只有一个成功。返回 true 表示"我是 Leader"。
L05

setLease 写入

setLeaseplugin_wrapper.go:610-627):把自己的 VMID + 当前时间戳序列化,用带 casSetSharedData 写入。

lease := Lease{VMID: ctx.vm.vmID, Timestamp: timestamp}
leaseByte, _ := json.Marshal(lease)
if err := proxywasm.SetSharedData(ctx.leaderLeaseKey(), leaseByte, cas); err != nil {
    return false   // CAS 失败(别人抢先了),我没当选
}
return true        // 写入成功,我是 Leader
CAS 失败就认输 如果 SetSharedData 因 cas 不匹配失败,说明有别的 VM 在你读和写之间抢先改了租约——你老实认输返回 false。这就是 CAS 保证唯一性的地方:并发抢租约时,只有拿到正确 cas 并抢先写入的那个成功。leaderLeaseKey:572-574)用插件指纹拼 key,不同插件互不干扰。
L06

配合 tick 续租

DoLeaderElectionplugin_wrapper.go:568-570)调 tryAcquireOrRenewLease 并更新 isLeader。典型用法是在 tick 里定期调(Day 10 的定时任务):

func parseConfig(ctx wrapper.PluginContext, json gjson.Result, config *Config) error {
    wrapper.RegisterTickFunc(3000, func() {   // 每 3 秒
        ctx.DoLeaderElection()                // 续租/抢租
        if ctx.IsLeader() {
            reportMetrics()                    // 只有 Leader 上报
        }
    })
    return nil
}
tick + 选举 = 自动故障转移 每 3 秒跑一次选举:Leader 借此续租(刷新时间戳);非 Leader 借此检查"现任 Leader 是否过期"。Leader 挂了 → 停止续租 → 60 秒后租约过期 → 下次某个 VM 的 tick 检测到过期 → 抢成新 Leader。整个过程无需人工干预、无需外部协调服务——纯靠 SharedData+CAS+tick 自组织。这是很优雅的分布式设计。
📝 单步走查:Leader 挂了之后发生了什么 t=0s VM-A 是 Leader,每 3 秒 tick 续租,Timestamp 一直刷新。
t=10s VM-A 所在实例崩溃 → 不再 tick → 租约的 Timestamp 停在 10s。
t=13/16/…s VM-B 的 tick 照常跑 DoLeaderElection,读到租约还是 A 的、且 now-Timestamp 还没超 60s → B 判定"现任有效",不抢,继续待命。
t=71s B 的某次 tick 发现 now-Timestamp = 61 > 60 → 租约过期 → B 用 CAS 抢写成功 → B 成为新 Leader,独占任务自动接管,全程无人干预。
L07

IsLeader 用法

IsLeader()plugin_wrapper.go:629-631)返回当前 VM 是否是 Leader。在需要"只干一次"的地方判断它。

读法:典型场景:定时上报统计、定时清理、定时拉取远端配置——用 if ctx.IsLeader() 包起来,保证全局只有一个实例执行。注意 isLeader 是每次 DoLeaderElection 后更新的快照,所以要在 tick 里持续选举保持新鲜。这个能力让单个 Wasm 插件也能安全地做"集群级单例任务"。
⚠️ 常见误解:小白以为"调一次 DoLeaderElection 当选后就永远是 Leader"。其实 isLeader 只是上一次选举后的快照——必须放进 tick 里持续续租,否则时间戳不刷新、60 秒后被判过期,你会"莫名"丢失 Leader 身份。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 为什么需要 Leader 选举?哪些任务只该一个实例干?
  • SharedData + CAS 怎么在竞争中选唯一赢家?
  • 为什么用租约而不是永久任命?60 秒过期的意义?
  • 怎么配合 tick 实现自动故障转移?

✋ 动手

cd /Users/bitmart/work/codes/github/higress-group/wasm-go
sed -n '563,631p' pkg/wrapper/plugin_wrapper.go
grep -n "GetSharedData\|SetSharedData" pkg/wrapper/plugin_wrapper.go pkg/matcher/*.go
明天预告 · Day 18AI Token 用量解析——tokenusage 包怎么从各家大模型(OpenAI/Gemini/Anthropic)不同格式的响应里,统一提取出 Token 用量。这是 AI 网关计费/限流的数据源头。
← Day 16 Day 18 · AI Token →