Files
ModelRouter/internal/lua/fastvalue.go
JianFeeeee 30064696b3 perf(plugin): 去掉钩子热路径的 JSON 往返 + 同 stage 跨插件并行
## 1. 去掉 JSON 往返(快路径)
实测单次 Fire 14.6µs,其中 json.Marshal 4.0 + json.Unmarshal 5.6 = 9.6µs,
**67% 花在把 map[string]interface{} 序列化再反序列化**,而紧接着的
pushGoValue 本来就能直接遍历这两种类型。改为按类型直接转换(fastvalue.go),
只对不认识���类型才回落 JSON —— 陌生字段仍然会被送到插件,而不是消失。

快路径与 JSON 路径逐字节等价由 TestFastPathMatchesJSONPath 锁住(8 组载荷,
覆盖 int/uint/float 各宽度、嵌套、slice、map[string]string、未知类型)。
还有一条专门防止「优化悄悄失效」:TestFastPathIsActuallyUsed 用真实的
request_end 载荷断言它确实走快路径。

同一份代码 A/B 实测:JSON 往返 56.4µs → 快路径 35.3µs(省 37%)。

## 2. 同 stage 跨插件并行
参照 /home/program/TrueAgent 的 StageHost.RunStage:
  - **快照后释放锁**再并行 —— 它记录过一次自死锁(p.Stop → onExit → ReclaimOwner
    要拿 registry 锁,持锁并行即死锁)。这里同理:钩子可能经 admin API 增删插件,
    那条路径要拿 ps.mu 写锁,所以并行段内不持任何 ps 锁。
  - 每个 goroutine recover。
  - 单插件走直连路径,不付 goroutine 代价(生产就是这种配置)。

**与 TrueAgent 不同的一点**:它可以放心并行,因为 handler 只写 ctx.Response 并有
IsResponded() 仲裁;我们的钩子返回 table 会合并进 payload,而
docs/plugins.md 明确承诺「payload 原样传给下一个插件」。所以合并**按插件加载
顺序**执行,结果确定,不依赖调度;代价是钩子之间不再互相可见 —— 这是一处
**契约变化**,已在文档里写明,并说明随核心发布的 billing 从不返回任何值
(代码注释就写着 "nobody downstream would read a return value")。

实测收益(真实二进制,三实例对照,3000 请求):

              无插件     1 插件      4 插件
  稳态并发32    849 rps   768 (-9.5%) 741 (-12.7%)
  突发并发64   1524-1893  1182-1676  1064-1443

4 插件只降 10-20%,而并行前实测 4 插件是 63.8µs vs 单插件 14.6µs(-300%)。

## ★ 我自己造成的两次性能事故
**① 持久化把热路径拖慢 26 倍。** 最初的快照在钩子路径上做:走 luaValueToGo +
json.Marshal + json.Unmarshal 三重转换,每请求 264µs,Fire 从 14.6µs 变成 385µs。
改成 saver 按自己节奏拉取(钩子只标记 dirty,flush 时才快照),385µs → 25.7µs。
**这里还踩了第二次 use-after-free**:让后台 goroutine 去读 Lua 表,vm.Stop() 后
那是已释放内存(SIGSEGV)。安全性现在由「Plugins.Close 等 saver 的最后一次
flush 完成后,调用方才停 VM」保证。

**② 基准被自己的后台写入污染。** 关掉 markDirty 反而测出 36µs、比开着还慢,
方向完全反了 —— 是 saver 每 2 秒写盘混进了计时。加了 DisableStatePersistence
后数据才可信。

## 判据(11 项,全部变异验证)
快路径等价/确实生效/不别名输入 + 并行与单插件路径合并一致 + 合并顺序确定 +
抛异常的钩子不拖累同伴 + 每插件恰好执行一次 + 并发 Fire 安全 + Fire 期间不持
注册表锁 + 真实 billing 在并行下正常 + 持久化 7 项。

变异:改坏合并顺序 → 红;去掉单插件路径的合并 → 红(3 个既有测试同时抓到)。
★ 「删掉 recover」这个变异**没有**让判据变红,查下去发现 golua 把 error()、
nil 索引、调用 nil、深递归全部转成 error RETURN,不产生 Go panic —— 那个测试
根本没测到 recover。已改名 TestThrowingHook 并在注释里写明 recover() 当前无法
被 Lua 触达,保留它是为了守 Go 侧。留一个「看起来有覆盖」的断言比没有更糟。

## 端到端(真实二进制 + 真实 billing)
20 万请求全 200,rps 1870,p99 96ms,RSS 37.9→42MB 有界;
负载停止后四个插件计数**完全一致**(231745),hook_errors 为空;
systemctl restart 后 billing 仍是 231745 —— 并行与持久化同时生效。
381 个测试全绿,含 -race。
2026-10-02 10:45:20 +08:00

140 lines
4.2 KiB
Go

package lua
import (
"encoding/json"
"strconv"
)
// This file exists to remove the JSON round-trip from the plugin hook path.
//
// Measured before this change: one Fire() call cost 14.6us, of which
// json.Marshal(payload) was 4.0us and json.Unmarshal back into interface{} was
// 5.6us — 67% of the whole hook, spent re-deriving a tree that the very next
// line (pushGoValue) already knows how to walk natively.
//
// The round-trip existed because payload arrives as map[string]interface{} and
// pushGoValue wanted a plain interface{} tree. pushGoValue already handles
// map[string]interface{} and []interface{} directly, so the fix is a converter
// that flattens the concrete Go types the gateway actually produces, instead of
// a generic serialize/parse.
//
// The JSON path is kept as the fallback for types the fast path does not know,
// so a plugin payload carrying something exotic still arrives rather than being
// silently dropped.
// jsonNumberType is the type json.Unmarshal produces for every JSON number.
// The fast path recognizes it so numbers survive as numbers rather than being
// stringified into a different Lua type.
type jsonNumberType = float64
// fastToPlain converts the concrete Go values the gateway puts in a hook
// payload into the interface{} tree pushGoValue consumes, without JSON.
//
// The interesting cases are the ones json.Unmarshal would have normalized:
// - json.Number-ish types (all float64 in practice)
// - int / int64 / uint variants, which must land as Lua numbers, not as the
// strings a naive "only handle float64" switch would produce
// - []string and map[string]string, extremely common in payloads and NOT
// handled by a switch that only knows []interface{}
//
// Anything unrecognized returns (value, false) so the caller can fall back to
// the JSON path, which is slower but total.
func fastToPlain(v interface{}) (interface{}, bool) {
switch x := v.(type) {
case nil, bool, string, jsonNumberType:
return v, true
case int:
return float64(x), true
case int8:
return float64(x), true
case int16:
return float64(x), true
case int32:
return float64(x), true
case int64:
return float64(x), true
case uint:
return float64(x), true
case uint8:
return float64(x), true
case uint16:
return float64(x), true
case uint32:
return float64(x), true
case uint64:
return float64(x), true
case float32:
return float64(x), true
case []string:
out := make([]interface{}, len(x))
for i, s := range x {
out[i] = s
}
return out, true
case map[string]string:
out := make(map[string]interface{}, len(x))
for k, s := range x {
out[k] = s
}
return out, true
case map[string]interface{}:
// The overwhelmingly common case, and the one the hook path always
// takes at the top level.
out := make(map[string]interface{}, len(x))
for k, it := range x {
c, ok := fastToPlain(it)
if !ok {
return nil, false
}
out[k] = c
}
return out, true
case []interface{}:
out := make([]interface{}, len(x))
for i, it := range x {
c, ok := fastToPlain(it)
if !ok {
return nil, false
}
out[i] = c
}
return out, true
default:
return nil, false
}
}
// plainForLua returns the interface{} tree to hand to pushGoValue, using the
// fast path when it can and JSON only when it must.
func plainForLua(payload map[string]interface{}) interface{} {
if fast, ok := fastToPlain(payload); ok {
return fast
}
// Rare: some type the fast path does not model. Serialize and let the
// generic decoder normalize it, so the hook still sees the field.
b, err := json.Marshal(payload)
if err != nil {
return payload
}
var decoded interface{}
if err := json.Unmarshal(b, &decoded); err != nil {
return payload
}
return decoded
}
// toPlainSlice is the []interface{} entry point of fastToPlain, exposed so the
// Fire path can flatten a pre-built slice without re-walking the map header.
func toPlainSlice(v []interface{}) []interface{} {
if fast, ok := fastToPlain(v); ok {
return fast.([]interface{})
}
return v
}
// luaNumberString renders a float the way Lua would print it, for the rare case
// a hook wants the textual form. Kept out of the hot path.
func luaNumberString(f float64) string {
return strconv.FormatFloat(f, 'g', -1, 64)
}