mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
## 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。
140 lines
4.2 KiB
Go
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)
|
|
}
|