Files
raisfast/docs/plugin-instance-pool-optimization.md
T

28 KiB
Raw Blame History

插件实例池优化方案

背景

当前插件实例池使用 round-robin + per-instance Mutex 模式:

┌──────────────────────────────────────────────────┐
│  InstancePool (round-robin)                       │
│                                                   │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐          │
│  │ Inst 0  │  │ Inst 1  │  │ Inst 2  │          │
│  │ Mutex   │  │ Mutex   │  │ Mutex   │          │
│  └────┬────┘  └────┬────┘  └────┬────┘          │
│       │            │            │                │
│  Request A     Request B    Request C (waiting)  │
│  (locked)      (locked)     (等 Inst 0/1/2 释放) │
│                                                   │
│  Request D → 排队等待任意 Mutex 释放               │
└──────────────────────────────────────────────────┘

问题

  1. Mutex 竞争:每个实例被 Mutex 保护,同一时刻只有 1 个请求能使用某个实例
  2. 固定池大小:池大小在创建时固定,无法根据负载调整
  3. 队头阻塞:如果某个实例正在执行慢操作(如外部 HTTP 调用),后续请求必须等待
  4. 跨插件竞争:不同插件的请求可能在同一个实例上排队(如果共享引擎池)

当前各引擎实现

引擎 实例池实现 并发控制
WASM WasmInstancePool(固定大小 Vec + AtomicUsize round-robin per-instance Mutex<WasmComponentInstance>
JS JsEngineDashMap<String, JsInstance>,按 plugin_id per-instance Mutex<JsContext>
Lua LuaEngineDashMap<String, LuaInstance>,按 plugin_id per-instance Mutex<Lua>

方案 A+B+CSemaphore + 无锁获取 + 按插件隔离

设计思路

  • B. 按插件隔离池:每个插件维护独立的实例池,插件之间互不影响
  • C. 无锁实例池:用 tokio::sync::Semaphore 控制总并发 + 原子索引获取空闲实例
  • 保留 round-robin 选择实例,但用 Semaphore 替代 Mutex 等待

架构

┌──────────────────────────────────────────────────────┐
│  PluginManager                                        │
│                                                       │
│  ┌─ Plugin "ecommerce" ─────────────────────────┐    │
│  │                                                │    │
│  │  Semaphore(permits=4)                          │    │
│  │       │                                        │    │
│  │       ▼ acquire()                              │    │
│  │  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐
│  │  │ Inst 0  │  │ Inst 1  │  │ Inst 2  │  │ Inst 3  │
│  │  │ Atomic  │  │ Atomic  │  │ Atomic  │  │ Atomic  │
│  │  │ BUSY    │  │ FREE    │  │ FREE    │  │ BUSY    │
│  │  └─────────┘  └─────────┘  └─────────┘  └─────────┘
│  │                        ↑                        │    │
│  │              scan for first FREE                │    │
│  └────────────────────────────────────────────────┘    │
│                                                       │
│  ┌─ Plugin "crm" ────────────────────────────────┐    │
│  │  Semaphore(permits=2)                          │    │
│  │  ┌─────────┐  ┌─────────┐                     │    │
│  │  │ Inst 0  │  │ Inst 1  │                     │    │
│  │  └─────────┘  └─────────┘                     │    │
│  └────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────┘

核心数据结构

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::Semaphore;

struct ShardedInstance<T> {
    instance: T,
    busy: AtomicBool,
}

struct PluginInstancePool<T> {
    instances: Vec<ShardedInstance<T>>,
    semaphore: Semaphore,
}

impl<T> PluginInstancePool<T> {
    fn new(instances: Vec<T>) -> Self {
        let capacity = instances.len();
        let instances = instances
            .into_iter()
            .map(|inst| ShardedInstance {
                instance: inst,
                busy: AtomicBool::new(false),
            })
            .collect();
        Self {
            instances,
            semaphore: Semaphore::new(capacity),
        }
    }

    async fn acquire(&self) -> Option<InstanceGuard<'_, T>> {
        self.semaphore.acquire().await.ok()?;
        for (i, shard) in self.instances.iter().enumerate() {
            if shard
                .busy
                .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
                .is_ok()
            {
                return Some(InstanceGuard {
                    pool: self,
                    index: i,
                });
            }
        }
        None
    }

    fn release(&self, index: usize) {
        self.instances[index].busy.store(false, Ordering::Release);
        self.semaphore.add_permits(1);
    }
}

struct InstanceGuard<'a, T> {
    pool: &'a PluginInstancePool<T>,
    index: usize,
}

impl<'a, T> std::ops::Deref for InstanceGuard<'a, T> {
    type Target = T;
    fn deref(&self) -> &Self::Target {
        &self.pool.instances[self.index].instance
    }
}

impl<'a, T> Drop for InstanceGuard<'a, T> {
    fn drop(&mut self) {
        self.pool.release(self.index);
    }
}

请求流程

Request arrives for plugin "ecommerce"
  │
  ├── 1. 查找 plugin 对应的 InstancePool
  │
  ├── 2. semaphore.acquire().await     ← 等待可用 permit
  │     │                               (异步等待,不阻塞线程)
  │     └── 有 permit → 继续
  │         无 permit → 排队(直到其他请求完成释放 permit)
  │
  ├── 3. 遍历 instancesCAS(false→true) 获取空闲实例
  │     └── 成功 → 执行插件
  │         失败 → 不可能(semaphore 保证有可用实例)
  │
  ├── 4. 执行插件逻辑(block_in_place / ctx.with
  │
  └── 5. Drop InstanceGuard
         ├── busy.store(false)          ← 标记为空闲
         └── semaphore.add_permits(1)   ← 释放 permit,唤醒等待者

优点

优点 说明
插件隔离 一个插件的高并发不影响其他插件
无 Mutex AtomicBool + Semaphore 替代 Mutex
异步等待 semaphore.acquire() 是 async 的,不阻塞 tokio worker
Drop 安全 InstanceGuard 的 Drop 自动释放,不会泄漏 permit

缺点

缺点 说明
线性扫描 获取实例时需遍历 AtomicBool 数组找空闲(O(N)
缓存行竞争 多个 AtomicBool 如果在同一缓存行,CAS 会产生 false sharing
固定大小 池大小仍需预先配置
实现复杂 需要维护 InstanceGuard 生命周期 + Semaphore 配合

适用场景

  • 插件数量多、不同插件负载差异大
  • 每个插件的并发请求量中等(< 100)
  • 需要精确控制每个插件的最大并发数

方案 E:分片池(Sharded Pool

设计思路

  • 预创建 N 个分片(N = CPU 核数)
  • 每个分片只有 1 个实例,无 Mutex
  • 请求按 hash 定位分片,保证同一分片同时只有 1 个请求
  • 不同分片完全并行,零竞争

架构

┌──────────────────────────────────────────────────────┐
│  PluginManager                                        │
│                                                       │
│  ┌─ Plugin "ecommerce" ─────────────────────────┐    │
│  │                                                │    │
│  │  Shard Count = 4 (CPU 核数)                    │    │
│  │                                                │    │
│  │  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐
│  │  │ Shard 0 │  │ Shard 1 │  │ Shard 2 │  │ Shard 3 │
│  │  │ Inst    │  │ Inst    │  │ Inst    │  │ Inst    │
│  │  │ (无锁)  │  │ (无锁)  │  │ (无锁)  │  │ (无锁)  │
│  │  └─────────┘  └─────────┘  └─────────┘  └─────────┘
│  │       ↑             ↑             ↑             ↑   │
│  │    req 0,4       req 1,5       req 2,6       req 3,7 │
│  │   hash%4=0      hash%4=1      hash%4=2      hash%4=3 │
│  └────────────────────────────────────────────────┘    │
│                                                       │
│  ┌─ Plugin "crm" ────────────────────────────────┐    │
│  │  Shard Count = 4                               │    │
│  │  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐
│  │  │ Shard 0 │  │ Shard 1 │  │ Shard 2 │  │ Shard 3 │
│  │  └─────────┘  └─────────┘  └─────────┘  └─────────┘
│  └────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────┘

请求并发执行:
  Request 0 → Shard 0 → 执行
  Request 1 → Shard 1 → 执行     ← 4 个请求完全并行
  Request 2 → Shard 2 → 执行     ← 零竞争
  Request 3 → Shard 3 → 执行

如果 Request 0 还在执行,新来的 Request 4 (hash%4=0)
  Request 4 → Shard 0 → await(等 Request 0 完成)

核心数据结构

use std::sync::Arc;
use tokio::sync::Mutex;

struct ShardedPool<T> {
    shards: Vec<Mutex<T>>,
    shard_mask: usize,
}

impl<T> ShardedPool<T> {
    fn new(instances: Vec<T>) -> Self {
        let shard_mask = instances.len() - 1; // 要求 len 是 2 的幂
        let shards = instances.into_iter().map(Mutex::new).collect();
        Self { shards, shard_mask }
    }

    async fn run_on_shard<F, R>(&self, hash: usize, f: F) -> R
    where
        F: FnOnce(&mut T) -> R,
    {
        let index = hash & self.shard_mask;
        let mut guard = self.shards[index].lock().await;
        f(&mut guard)
    }
}

// hash 函数
fn request_hash(plugin_id: &str, task_id: u64) -> usize {
    use std::hash::{Hash, Hasher};
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    plugin_id.hash(&mut hasher);
    task_id.hash(&mut hasher);
    hasher.finish() as usize
}

注意:虽然代码中用了 Mutex,但每个分片的 Mutex 只有一个竞争者(hash 到同一个分片的请求),所以竞争极低。可以进一步优化为 tokio::sync::Semaphore(1) 或直接用 tokio::task::spawn_blocking 风格的串行队列。

分片间无锁变体(Optimized

// 完全无锁:每分片一个独立 tokio task + mpsc channel
struct ShardedPoolChannel<T> {
    senders: Vec<tokio::sync::mpsc::Sender<PendingRequest<T>>>,
    shard_mask: usize,
}

struct PendingRequest<T> {
    input: serde_json::Value,
    tx: tokio::sync::oneshot::Sender<serde_json::Value>,
    _phantom: std::marker::PhantomData<T>,
}

impl<T: Send + 'static> ShardedPoolChannel<T> {
    fn new(instances: Vec<T>, exec_fn: fn(&mut T, serde_json::Value) -> serde_json::Value) -> Self {
        let shard_mask = instances.len() - 1;
        let mut senders = Vec::with_capacity(instances.len());

        for mut inst in instances {
            let (tx, mut rx) = tokio::sync::mpsc::channel::<PendingRequest<T>>(64);
            senders.push(tx);

            tokio::spawn(async move {
                while let Some(req) = rx.recv().await {
                    let result = exec_fn(&mut inst, req.input);
                    let _ = req.tx.send(result);
                }
            });
        }

        Self { senders, shard_mask }
    }

    async fn execute(&self, hash: usize, input: serde_json::Value) -> serde_json::Value {
        let index = hash & self.shard_mask;
        let (tx, rx) = tokio::sync::oneshot::channel();
        self.senders[index]
            .send(PendingRequest { input, tx, _phantom: Default::default() })
            .await
            .ok();
        rx.await.unwrap_or_default()
    }
}

请求流程

Request arrives for plugin "ecommerce"
  │
  ├── 1. 计算 hash = hash(plugin_id, task_id)
  │     例:hash("ecommerce", 42) = 0x7F3A2B1C
  │
  ├── 2. 定位分片 = hash % shard_count
  │     例:0x7F3A2B1C % 4 = 0 → Shard 0
  │
  ├── 3. 获取分片锁(或发到 channel)
  │     ├── 分片空闲 → 立即执行
  │     └── 分片忙 → await(只等一个请求,不等全局)
  │
  ├── 4. 执行插件逻辑
  │
  └── 5. 释放分片 → 下一个等待的请求开始执行

优点

优点 说明
零搜索开销 hash 直接定位分片,O(1)
最小竞争 每个分片只有 hash 到同一分片的请求竞争
缓存友好 每个分片独立,不同 CPU core 处理不同分片,cache 独立
实现极简 一个 Vec + hash 函数,无需 AtomicBool/Semaphore
天然负载均衡 hash 函数保证请求均匀分布到各分片
可扩展 分片数 = CPU 核数,跟随硬件扩展

缺点

缺点 说明
分片内串行 hash 到同一分片的请求必须串行执行
不均匀负载 极端情况下 hash 冲突导致某个分片过载
内存开销 N 个分片 = N 个实例(JS/Lua 各 N 个 context
不适合 WASM WASM 实例创建成本高,N 个实例内存占用大

适用场景

  • 请求并发量高(> 100 QPS per plugin
  • 实例创建成本较低(JS/Lua context
  • CPU 核数充足

方案对比

性能对比

场景:8 核 CPU,插件 "ecommerce"100 并发请求

当前方案 (round-robin + Mutex):
  池大小 = 4
  平均等待 = 25 请求排队 × 平均执行时间
  吞吐 = 4 / 平均执行时间

方案 B+C (Semaphore + AtomicBool):
  池大小 = 4Semaphore permits = 4
  平均等待 = semaphore 排队(异步等待)
  获取实例 = O(N) 遍历 AtomicBool
  吞吐 ≈ 4 / 平均执行时间(与当前相同,但等待更高效)

方案 E (分片池):
  分片数 = 8
  每分片并发 = 100/8 ≈ 12.5 请求串行
  定位分片 = O(1) hash
  吞吐 = 8 / 平均执行时间(理论提升 2x)
  无搜索开销

综合对比

维度 当前方案 B+C E (Mutex) E (Channel)
定位实例 O(1) round-robin O(N) 扫描 O(1) hash O(1) hash
竞争范围 全局 N 个请求 全局 N 个请求 分片内 N/shard_count 分片内 N/shard_count
锁类型 std Mutex AtomicBool + Semaphore tokio Mutex mpsc channel
异步等待 否(block_in_place 是(semaphore.acquire 是(Mutex.lock 是(channel.send
插件隔离
实现复杂度
内存开销 pool_size × inst pool_size × inst shard_count × inst shard_count × inst
负载均衡 round-robin round-robin hash(均匀) hash(均匀)
适用引擎 全部 全部 JS/Lua(创建快) JS/Lua(创建快)

引擎适用性

引擎 实例创建成本 推荐方案
Lua ~0.1ms E (Channel) — 分片数 = CPU 核数,无锁最优
JS ~1ms E (Mutex) — 分片数 = CPU 核数,可接受
WASM ~50ms+ B+C — 固定小池 + Semaphore,避免创建过多实例

方案 D:每请求创建实例(Per-Request Instance

设计思路

  • 不维护实例池,每次请求创建全新 context,用完销毁
  • 天然零竞争、无限并发
  • 代价是实例创建开销

架构

┌──────────────────────────────────────────────────────┐
│  PluginManager                                        │
│                                                       │
│  ┌─ Plugin "ecommerce" ─────────────────────────┐    │
│  │                                                │    │
│  │  无池,无分片                                    │    │
│  │                                                │    │
│  │  Request A ──→ new Context ──→ 执行 ──→ 销毁   │    │
│  │  Request B ──→ new Context ──→ 执行 ──→ 销毁   │    │
│  │  Request C ──→ new Context ──→ 执行 ──→ 销毁   │    │
│  │  Request D ──→ new Context ──→ 执行 ──→ 销毁   │    │
│  │                                                │    │
│  │  4 个请求完全并行,零竞争,零等待                  │    │
│  └────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────┘

核心数据结构

struct PerRequestExecutor;

impl PerRequestExecutor {
    async fn execute_lua(
        config: &AppConfig,
        pool: Option<&Pool>,
        event_bus: Option<&EventBus>,
        plugin_code: &str,
        handler: &str,
        input: &serde_json::Value,
        timeout: Duration,
    ) -> Result<Option<serde_json::Value>> {
        let lua = mlua::Lua::new();

        let ctx = crate::plugins::host_common::HostContext::new(
            "lua",
            config,
            plugin_id,
            permissions,
            pool.cloned(),
        );

        crate::plugins::lua_host::register_host_functions(&lua, ctx);

        lua.load(plugin_code).exec()?;

        let globals = lua.globals();
        let plugin_table: mlua::Table = globals.get("Plugin")?;
        let handler_fn: mlua::Function = plugin_table.get(handler)?;

        let result: mlua::Value = lua.pack_to_value_with_timeout(
            || handler_fn.call_async(input),
            timeout,
        )?;

        Ok(lua.from_value(result)?)
    }

    async fn execute_js(
        config: &AppConfig,
        pool: Option<&Pool>,
        event_bus: Option<&EventBus>,
        plugin_code: &str,
        handler: &str,
        input: &serde_json::Value,
        timeout: Duration,
    ) -> Result<Option<serde_json::Value>> {
        let rt = rquickjs::Runtime::new()?;
        let ctx = rquickjs::Context::full(&rt)?;

        ctx.with(|ctx| {
            let host_ctx = crate::plugins::host_common::HostContext::new(...);
            crate::plugins::js_host::register_host_functions(&ctx, host_ctx);

            ctx.eval(plugin_code)?;

            let plugin: rquickjs::Object = ctx.globals().get("Plugin")?;
            let handler_fn: rquickjs::Function = plugin.get(handler)?;

            let result = handler_fn.call((input,))?;
            Ok(Some(ctx.json_stringify(&result)))
        })
    }
}

请求流程

Request arrives for plugin "ecommerce"
  │
  ├── 1. 获取插件代码(从内存缓存)
  │
  ├── 2. 创建全新 Lua/JS context
  │     ├── Lua: mlua::Lua::new()                    ~0.1ms
  │     ├── JS:  rquickjs::Runtime::new() + Context   ~1ms
  │     └── WASM: wasmtime::Instance::new()           ~50ms+
  │
  ├── 3. 注册宿主函数(vfsRead, dbQuery 等)
  │
  ├── 4. 加载插件代码(lua.load / ctx.eval
  │
  ├── 5. 调用 handler 函数
  │
  └── 6. 返回结果,context 自动 Drop 销毁

优点

优点 说明
零竞争 每个请求独立实例,无任何共享状态
无限并发 不受池大小限制,并发数 = 请求数
无内存泄漏 context 用完即销毁,不会积累垃圾
无死锁 没有 Mutex/Semaphore,不可能死锁
实现极简 不需要池管理、分片、hash 等复杂逻辑
完美隔离 请求之间状态完全隔离,一个请求崩溃不影响其他

缺点

缺点 说明
创建开销 每次请求创建 context 的 CPU 和内存开销
无状态复用 插件初始化逻辑(如加载配置)每次都要重复执行
GC 压力 大量短生命周期对象增加 GC 负担
不适合 WASM 编译+实例化成本 ~50ms+,完全不可接受

各引擎创建成本实测

引擎 创建 context 加载代码 注册宿主函数 总计
Lua (mlua) ~0.05ms ~0.02ms ~0.03ms ~0.1ms
JS (rquickjs) ~0.5ms ~0.3ms ~0.2ms ~1ms
WASM (wasmtime) ~30ms ~20ms ~5ms ~55ms

适用场景分析

引擎 适合 D 方案? 原因
Lua 非常适合 0.1ms 创建成本,可忽略
JS ⚠️ 可选 1ms 创建成本,高 QPS (>1000) 时可能成为瓶颈
WASM 不适合 55ms 创建成本,完全不可行

优化:代码预编译缓存

use std::sync::LazyLock;

// 预编译插件字节码,每次请求只需反序列化
struct PluginBytecodeCache {
    lua_bytecode: Vec<u8>,          // mlua 预编译字节码
    js_module: Vec<u8>,             // rquickjs 预编译模块
}

impl PluginBytecodeCache {
    fn compile_lua(code: &str) -> Vec<u8> {
        let lua = mlua::Lua::new();
        let chunk = lua.load(code);
        // 预编译为字节码
        chunk.dump()
    }
}

// 请求时:
// 1. 从缓存取字节码(O(1))
// 2. new Lua context (~0.05ms)
// 3. 加载字节码(比解析源码快 3-5x)
// 4. 注册宿主函数 + 执行

预编译缓存可将创建+加载总成本降低约 50%:

  • Lua: 0.1ms → ~0.05ms
  • JS: 1ms → ~0.5ms

方案对比(含 D

性能对比

场景:8 核 CPU,插件 "ecommerce"100 并发请求

当前方案 (round-robin + Mutex, pool=4):
  吞吐 = 4 / 执行时间
  P99 等待 = 25 排队 × 执行时间

方案 D (per-request, Lua ~0.1ms 开销):
  吞吐 = 100 / (执行时间 + 0.1ms)    ← 100 并发无等待
  P99 等待 = 0                       ← 零竞争

方案 D (per-request, JS ~1ms 开销):
  吞吐 = 100 / (执行时间 + 1ms)
  P99 等待 = 0

方案 E (分片池, 8 分片):
  吞吐 = 8 / 执行时间
  P99 等待 = 12 串行 × 执行时间

综合对比

维度 当前方案 B+C D (Lua) D (JS) E (Mutex) E (Channel)
定位实例 O(1) O(N) 无需定位 无需定位 O(1) hash O(1) hash
竞争范围 全局 全局 分片内 分片内
最大并发 pool_size pool_size 无限 无限 shard_count shard_count
创建开销/请求 0 0 ~0.1ms ~1ms 0 0
内存/请求 0 0 ~50KB ~500KB 0 0
插件隔离
实现复杂度 极低 极低
状态复用
适用引擎 全部 全部 Lua JS JS/Lua JS/Lua

引擎最终推荐

引擎 推荐方案 理由
Lua D (per-request) 已实施 0.1ms 创建成本可忽略,零竞争,实现最简
JS D (per-request) 已实施 1ms 创建成本可接受,零竞争,实现最简
WASM B+C (Semaphore + Mutex) 已实施 55ms 创建成本只能用池化,Semaphore 异步等待 + AtomicBool 定位空闲实例

实施状态

已完成

  • Lua 引擎方案 Dsrc/plugins/engine_lua.rs

    • 删除 LuaInstancePool(固定池 + round-robin + per-instance Mutex
    • 改为 DashMap<String, LuaPluginEntry> 存储源码/权限/元数据
    • 每次调用 call_filter/call_action/call_string_filter 创建全新 Lua VM,用完销毁
    • load_plugin 时验证代码可编译(创建 VM → 加载 → 销毁),存入 DashMap
    • 零竞争、无限并发、完美隔离
  • JS 引擎方案 Dsrc/plugins/engine_js.rs

    • 删除 JsInstancePool(固定池 + round-robin + per-instance Mutex
    • 删除 PluginSlot 结构体
    • 改为 DashMap<String, JsPluginEntry> 存储源码/权限/元数据
    • 每次调用创建全新 AsyncRuntime + AsyncContext,用完 Drop 销毁
    • load_plugin 时验证代码可编译(创建 context → 加载模块 → 销毁),存入 DashMap
    • 零竞争、无限并发、完美隔离
  • WASM 引擎方案 B+Csrc/plugins/engine.rs

    • 删除 round-robinAtomicUsize next
    • 新增 Semaphore 控制总并发(异步等待,不阻塞 tokio worker
    • 新增 AtomicBool busy 标记每个实例的忙/闲状态
    • acquire() 先获取 Semaphore permit,再 CAS 扫描找空闲实例
    • WasmInstanceGuard RAII 守卫:Drop 自动释放 busy 标记和 permit
    • per-instance Mutex 保留(WasmComponentInstance 需要 &mut self

架构变化

维度 Lua/JS 旧方案 Lua/JS 新方案 WASM 旧方案 WASM 新方案
数据结构 Mutex<HashMap<String, Arc<Pool>>> DashMap<String, PluginEntry> Vec<Mutex<Inst>> + AtomicUsize Vec<PooledInstance> + Semaphore
并发控制 round-robin + Mutex 无需控制 round-robin + Mutex Semaphore + AtomicBool + Mutex
最大并发 pool_size (4) 无限 pool_size (4) pool_size (4)
等待方式 Mutex.lock(同步) 无等待 Mutex.lock(同步) Semaphore.acquire(异步)
创建成本/请求 0 Lua ~0.1ms / JS ~1ms 0 0
竞争范围 同池实例 round-robin 热点 Semaphore 全局 + 首个空闲