Day 14 / 共 20 天 · 第 3 周 Wasm 插件

实战插件

昨天(Day 13)读透了 key-auth 这个"单机、看请求头就能拍板"的简单插件;今天升级到更复杂的实战插件:Redis 分布式限流(leader 选举 + Lua 脚本)、transformer(改 header/body)、request-block(拦截)。这些展示插件的进阶能力——外部依赖、分布式协调、跨阶段状态。

📍 你在第 3 周(Wasm 插件)的位置
D11 插件概览 D12 wasm-go SDK D13 写插件 key-auth D14 实战插件 D15 golang-filter
🤔 痛点:单机限流一行代码就行,为什么到了网关就成了难题? 本地写"每分钟最多 100 次"很简单——一个计数器加一判断就完事。可网关是多副本的:3 个 Envoy 各自记各自的数,每个都放到 100,实际就放了 300,限流形同虚设。而且多个请求同时改计数还会算错(竞态)。"多副本共享 + 并发正确"这两道坎,才是分布式限流真正要解决的。
💡 先兜住今天(延续"网关=收费站"世界观) 限流就像高速收费站的车流管制:"这条路每分钟最多放 100 辆"。难点在于——一个收费站有好几个收费亭(多个 Envoy 副本),如果每个亭各记各的"我放了几辆",加起来就超了。解法是所有亭共用一本挂在墙上的总账(Redis 计数器),而且改账必须一笔一笔原子地记(Redis+Lua),不能两个亭同时抢着改。有些活(每天结算上报)只需一个亭长干,就推举一位亭长(leader 选举)。记住这个收费站画面,今天全通。
L01

限流插件

extensions/cluster-key-rate-limit——基于 Redis 的分布式限流。核心问题:多个 Envoy 实例怎么共享一个限流计数?

多个收费亭共享一本总账(分布式限流) Envoy 副本 A Envoy 副本 B Envoy 副本 C Redis 总账 count = 87 / 100(本分钟) Lua 脚本原子 读→判→+1 ✗ 若各记各账:3 副本各放 100 = 实际放了 300(超限 3 倍!)
图注:所有收费亭都对着同一本 Redis 总账记数、判超限。这样"限 100"才是全局的 100,而不是每个副本各 100。
分布式限流的难点 单实例限流简单(本地计数)。但网关有多个 Envoy 副本,每个副本本地计数会导致"总量超限"(比如限 100 QPS,3 个副本各放 100 就成了 300)。分布式限流要让所有副本共享一个计数器——放在 Redis 里。但又有并发问题:多个副本同时读-改-写 Redis 计数会竞态。解法:用 Redis + Lua 脚本(原子操作,L02)保证"读+判断+扣减"一气呵成。这就是为什么限流插件要用 Redis。AI 场景的 Token 限流(Day 17)也是这套。
L02

Redis + Lua

// SDK redis_wrapper.go 的 RedisClient.Eval(...) 执行 Lua 脚本
// 固定窗口限流:一段 Lua 原子地"读当前计数 → 判断是否超限 → 未超则 +1"
// 走底层 proxy_redis_call(Higress fork 加的 ABI,Envoy 课 Day 17)
为什么用 Lua 脚本? 限流要做三步:读计数、判断超没超、没超就加一。如果分三次 Redis 命令,中间会被别的请求插入(竞态)——两个请求同时读到 99,都以为没超,都放行,结果超了。Lua 脚本让这三步在 Redis 里原子执行(Redis 单线程跑完一个脚本才跑下一个),杜绝竞态。Higress 用 proxy_redis_call(Envoy 课 Day 17 讲的 Higress 定制 ABI)让 Wasm 插件能直接调 Redis。"原子操作解决并发竞态"是分布式限流的标准做法。
📝 举个例子:两个请求同时来,Lua 原子性怎么救场 限额 100,当前 count=99。两个请求几乎同时到 A、B 两个副本:
❌ 不用 Lua(分三次命令):A 读到 99、B 也读到 99 → 两个都判"没超" → 都 +1 放行 → 实际放到 101,超了
✅ 用 Lua(一段脚本原子跑):Redis 单线程,A 的脚本"读99→判未超→写100"整段跑完,B 的脚本才开始"读100→判到顶→拒绝(429)"。先到先得,绝不超发。
L03

leader 选举

某些操作(定时清理、统计上报)只需一个 Envoy worker 干,用 DoLeaderElection(Day 12,SharedData + CAS + 60s 租约)选一个 leader。

leader 选举 = "推举一个代表干活" 有些任务多个 worker 都做就重复了(比如"每分钟上报一次统计"——3 个 worker 就报 3 次)。leader 选举:多个 worker 通过 Redis/SharedData 竞争一个"租约",抢到的当 leader,只有它做这类任务;租约 60s 过期要续,leader 挂了别的 worker 接管。用 CAS(Compare-And-Swap 原子操作)保证同时只有一个能抢到。这是分布式系统"单实例任务"的经典模式(和 OpenClaw 的会话串行、Istio 的 leaderelection 同思想)。
L04

transformer

extensions/transformer——改写请求/响应的 header 和 body(增删改)。展示插件怎么修改流经的数据。

读法:transformer 用 SDK 的 AddHttpRequestHeader/RemoveHttpRequestHeader(改 header)和 body 缓冲能力(改 body)。改 body 要缓冲整个 body(BufferRequestBody)——所以要注册 ProcessRequestBody 回调,等 body 收齐再改。这是"数据改写类"插件的模板(AI 场景的 prompt 改写也是这套,Day 17)。
L05

request-block

extensions/request-block——最简单的拦截插件:按规则(URL/header/body 含某关键词)直接拒绝请求。

读法:它就是"匹配到就 SendHttpResponseWithDetail(403)"——最小的"拦截"插件。适合作为"如何拦截请求"的入门样例(比 key-auth 更简单)。拦截类插件(黑名单、WAF)都是这个套路:匹配 → 拒绝。
L06

跨阶段状态

请求头阶段 → 响应体阶段传值 复杂插件常需要跨阶段传状态:比如限流插件在请求头阶段判断是否放行,在响应体阶段统计实际消耗(AI Token 限流,Day 17)。HttpContextSetContext(key, val)/GetContext(key)(Day 12)在同一个请求的不同阶段间传值——因为 HttpContext 是每请求独立的,活在整个请求周期。比如请求头阶段存下"限流 key",响应体阶段取出来扣减。这是插件处理"需要看到请求全过程"的逻辑的关键机制。

👶 小白:请求头阶段算好的东西,为什么不用一个全局变量存,非要塞进 HttpContext?

👨‍🏫 老师:因为网关同时在处理成千上万个请求。用全局变量,A 请求存的"限流 key"会被 B 请求覆盖——串味了。HttpContext每个请求独立一份(Day 12 的请求级 Context),A 存的 A 自己取、B 存的 B 自己取,互不干扰。这就像收费站给每辆车发一张"随车小票"记录信息,而不是所有车共用一块黑板。

L07

单测 + 构建

插件有单测(main_test.go + proxytest 模拟 Envoy 环境),构建用官方 TinyGo builder 镜像(wasm-go-builder)编译成 .wasm,可推成 OCI 镜像(hgctl plugin build,Day 18)。

读法:插件用 proxytest(proxy-wasm 的测试框架)模拟 Envoy 调回调,本地就能测。构建用 TinyGo(Go 的 Wasm 编译器)在容器里编译(保证环境一致)。GOOS=wasip1 GOARCH=wasm 是 Wasm 编译目标。Day 18 讲 hgctl plugin 一键构建/测试/发布。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 分布式限流的难点?为什么要 Redis?
  • 为什么限流用 Lua 脚本(原子性)?
  • leader 选举解决什么?
  • transformer 怎么改 body?为什么要缓冲?
  • 跨阶段传值用什么?插件怎么测和构建?

✋ 动手

cd /Users/bitmart/work/codes/github/higress-group/higress/plugins/wasm-go/extensions
ls | head -40   # 看有多少插件
ls cluster-key-rate-limit/ transformer/ request-block/
明天预告 · Day 15(第3周收官)golang-filter——另一套扩展机制:原生 Go 扩展(非 Wasm),编译成 .so。用于 mcp-server/mcp-session(需要常驻 goroutine 维护 SSE 长连接的场景)。
← Day 13 写插件 Day 15 · golang-filter →