构建 APISIX 混合插件架构:结合 Go 与 C++ 实现动态高精度流量控制


要实现一套微秒级响应、规则动态可配的高精度流量控制系统,直接在网关层面拦截是最佳路径。Apache APISIX 提供了强大的插件机制,但当需求触及性能极限时,技术选型就变成了一场在开发效率、执行性能和维护成本之间的精细权衡。

我们的目标非常明确:

  1. 高精度限流: 实现基于令牌桶算法的速率控制,而非简单的固定窗口计数器。
  2. 动态配置: 限流规则(如速率、桶容量、匹配键)必须能通过管理端实时下发并生效,无需重启网关。
  3. 极致性能: 插件本身引入的延迟,P99.9 必须控制在 1ms 以内,尤其是在高并发场景下。

方案A:纯 Go 插件方案

APISIX 通过 go-plugin-runner 支持使用 Go 语言开发外部插件。这是一种非常主流且高效的方式。

优势:

  • 开发效率高: Go 拥有现代化的工具链、丰富的库和强大的并发模型。
  • 内存安全: 自带 GC,极大地降低了内存管理的复杂性。
  • 生态成熟: 网络、JSON 处理等库一应俱全。

劣势:

  • RPC 开销: Go 插件与 APISIX master 进程之间通过 Unix Domain Socket 进行 RPC 通信。尽管性能很高,但这层序列化/反序列化和 IPC 调用在高负载下依然是不可忽视的延迟来源。
  • GC 抖动 (Jitter): 对于 P99.9 延迟要求极为苛刻的场景,Go 的 GC 可能引入不可预测的 STW (Stop-The-World) 停顿,尽管现代 Go GC 已经非常优秀,但在微秒级的世界里,这仍是一个风险点。

方案B:Go + C++ 混合插件方案

这是我们最终选择的架构。核心思想是将插件的职责进行切分:

  • Go 层: 作为“胶水层”和“控制层”。负责与 APISIX go-plugin-runner 进行 RPC 通信,解析请求上下文,处理业务逻辑(如从请求头/body中提取限流 key),以及管理动态配置的加载。
  • C++ 层: 作为“性能核心”。实现一个高性能、线程安全的令牌桶算法库。所有与速率计算、令牌消耗相关的密集型操作都在这一层完成。Go 通过 CGO 调用 C++ 库。

选择该方案的理由:
在真实项目中,性能瓶颈往往集中在少数关键代码路径上。对于我们的限流插件而言,瓶颈就是令牌桶状态的计算与更新。这部分代码执行频率极高,且对延迟极其敏感。使用 C++ 实现这部分核心,可以获得:

  • 无 GC 停顿: 手动内存管理,消除了 GC 带来的延迟不确定性。
  • 极致的计算性能: C++ 编译器能生成高度优化的机器码,对 CPU Cache 的利用也更为直接可控。
  • 最小化 IPC 开销: Go 与 C++ 之间的 CGO 调用是在同一进程空间内的函数调用,其开销远小于 Go 插件与 APISIX 之间的跨进程 RPC。

当然,这种方案的代价是显著增加了复杂性:需要处理 CGO 的互操作性、内存管理边界,以及更复杂的构建流程。但为了实现极致的性能目标,这种权衡是值得的。

核心实现概览

整个系统分为三部分:高性能限流插件、动态配置管理服务,以及一个用于实时配置的前端界面。

graph TD
    subgraph "管理平面"
        AdminUI[Ant Design + Relay UI] -->|GraphQL Mutation| MgmtAPI[Go GraphQL API]
        MgmtAPI -->|Write Rules| Etcd
    end

    subgraph "数据平面 (APISIX Worker)"
        Client[Client] -->|Request| APISIX
        APISIX -->|RPC Call| GoRunner[go-plugin-runner]
        GoRunner -->|Request Context| GoPlugin[Go Plugin]
        GoPlugin -->|Watch| Etcd
        GoPlugin -->|CGO Call| CppCore[C++ TokenBucket Lib]
        GoPlugin -->|Decision| GoRunner
        GoRunner -->|Response| APISIX
        APISIX -->|Allow/Deny| Client
    end

    style GoPlugin fill:#f9f,stroke:#333,stroke-width:2px
    style CppCore fill:#bbf,stroke:#333,stroke-width:2px

1. C++ 核心:线程安全的令牌桶实现

这是性能基石。我们需要一个能被多个 Go Goroutine 并发调用的线程安全令牌桶容器。

token_bucket.h:

#ifndef TOKEN_BUCKET_H
#define TOKEN_BUCKET_H

#include <chrono>
#include <mutex>
#include <string>
#include <unordered_map>

struct BucketConfig {
    double rate;       // 每秒生成的令牌数
    double burst_size; // 桶的最大容量
};

struct BucketState {
    double tokens;
    std::chrono::steady_clock::time_point last_update;
};

class RateLimiter {
public:
    RateLimiter() = default;
    ~RateLimiter() = default;

    // 非拷贝构造
    RateLimiter(const RateLimiter&) = delete;
    RateLimiter& operator=(const RateLimiter&) = delete;

    // 更新或创建规则
    // @param key: 规则的唯一标识
    // @param rate: 速率
    // @param burst: 桶容量
    void update_rule(const std::string& key, double rate, double burst);

    // 尝试消费一个令牌
    // @param key: 要应用的规则 key
    // @return: true 如果成功消费, false 如果令牌不足
    bool consume(const std::string& key);
    
    // 删除规则
    void remove_rule(const std::string& key);

private:
    void refill(const std::string& key);

    std::unordered_map<std::string, BucketConfig> configs_;
    std::unordered_map<std::string, BucketState> states_;
    std::mutex mtx_; // 使用一个全局锁保护两个 map,实际生产环境可考虑更细粒度的锁
};

#endif // TOKEN_BUCKET_H

token_bucket.cpp:

#include "token_bucket.h"
#include <algorithm> // for std::min

void RateLimiter::update_rule(const std::string& key, double rate, double burst) {
    std::lock_guard<std::mutex> lock(mtx_);
    configs_[key] = {rate, burst};
    // 如果是新规则, 初始化其状态
    if (states_.find(key) == states_.end()) {
        states_[key] = {burst, std::chrono::steady_clock::now()};
    }
}

void RateLimiter::remove_rule(const std::string& key) {
    std::lock_guard<std::mutex> lock(mtx_);
    configs_.erase(key);
    states_.erase(key);
}

// refill 是核心逻辑,必须在持有锁的情况下调用
void RateLimiter::refill(const std::string& key) {
    auto config_it = configs_.find(key);
    if (config_it == configs_.end()) {
        return; // 规则不存在
    }

    auto state_it = states_.find(key);
    if (state_it == states_.end()) {
        return; // 状态不存在, 理论上不应发生
    }

    const auto& config = config_it->second;
    auto& state = state_it->second;

    auto now = std::chrono::steady_clock::now();
    std::chrono::duration<double> elapsed = now - state.last_update;

    // 计算在这段时间内应该生成的令牌数
    double tokens_to_add = elapsed.count() * config.rate;
    
    // 更新令牌数量, 不超过桶的容量
    state.tokens = std::min(config.burst_size, state.tokens + tokens_to_add);
    state.last_update = now;
}


bool RateLimiter::consume(const std::string& key) {
    std::lock_guard<std::mutex> lock(mtx_);
    
    // 检查规则是否存在
    if (configs_.find(key) == configs_.end()) {
        // 在生产环境中,对于未知 key 可能需要有默认行为,比如直接放行或拒绝
        // 这里我们假设规则必须预先定义,否则拒绝
        return false; 
    }

    // 消费前先补充令牌
    refill(key);

    auto& state = states_.at(key);
    if (state.tokens >= 1.0) {
        state.tokens -= 1.0;
        return true;
    }

    return false;
}

// C-style 接口,用于 CGO 调用
extern "C" {
    RateLimiter* RateLimiter_new() {
        return new RateLimiter();
    }

    void RateLimiter_delete(RateLimiter* limiter) {
        delete limiter;
    }

    void RateLimiter_update_rule(RateLimiter* limiter, const char* key, double rate, double burst) {
        if (limiter && key) {
            limiter->update_rule(std::string(key), rate, burst);
        }
    }

    bool RateLimiter_consume(RateLimiter* limiter, const char* key) {
        if (limiter && key) {
            return limiter->consume(std::string(key));
        }
        return false; // 默认拒绝
    }
    
    void RateLimiter_remove_rule(RateLimiter* limiter, const char* key) {
        if (limiter && key) {
            limiter->remove_rule(std::string(key));
        }
    }
}

关键设计点:

  • extern "C": 暴露了 C 风格的函数接口,这是 CGO 能够链接和调用的关键。
  • 线程安全: 使用 std::mutex 保护对 configs_states_ 的并发访问。在一个真实的生产级库中,可能会使用更高级的并发数据结构或分片锁来减少锁竞争,但 std::mutex 是一个清晰的起点。
  • 时间源: 使用 std::chrono::steady_clock,它保证是单调递增的,不受系统时间调整的影响,对于计算时间间隔至关重要。

2. Go CGO Bridge 和 APISIX 插件

现在,我们在 Go 代码中调用 C++ 库,并将其封装成一个 APISIX 插件。

main.go:

package main

/*
#cgo CXXFLAGS: -std=c++11
#cgo LDFLAGS: -lstdc++
#include <stdbool.h>

// 声明 C++ 导出的函数
typedef void RateLimiter;
RateLimiter* RateLimiter_new();
void RateLimiter_delete(RateLimiter* limiter);
void RateLimiter_update_rule(RateLimiter* limiter, const char* key, double rate, double burst);
bool RateLimiter_consume(RateLimiter* limiter, const char* key);
void RateLimiter_remove_rule(RateLimiter* limiter, const char* key);
*/
import "C"
import (
	"encoding/json"
	"fmt"
	"log"
	"unsafe"

	"github.com/apache/apisix-go-plugin-runner/pkg/plugin"
	"github.com/apache/apisix-go-plugin-runner/pkg/runner"
)

// 全局唯一的限流器实例
var limiter *C.RateLimiter

func init() {
	// 在程序启动时创建 C++ 对象实例
	limiter = C.RateLimiter_new()
	log.Println("C++ RateLimiter instance created.")
    
    // 这里应该启动一个 goroutine 去 watch etcd 或其他配置中心
    // 来动态调用 RateLimiter_update_rule 和 RateLimiter_remove_rule
    // go watchConfigChanges()
    // 为演示简单,我们硬编码几条规则
    key1 := C.CString("user-a")
    defer C.free(unsafe.Pointer(key1))
    C.RateLimiter_update_rule(limiter, key1, 10.0, 20.0) // 10 req/s, burst 20

    key2 := C.CString("ip-127.0.0.1")
    defer C.free(unsafe.Pointer(key2))
    C.RateLimiter_update_rule(limiter, key2, 2.0, 5.0) // 2 req/s, burst 5
    
    log.Println("Initial rate limit rules loaded.")
}

// PluginConf 是插件的配置结构体
type PluginConf struct {
	// KeyType 指明从哪里获取限流的 key
	// 支持 "header", "arg", "remote_addr"
	KeyType string `json:"key_type"`
	// KeyName 是当 KeyType 为 header 或 arg 时,具体的字段名
	KeyName string `json:"key_name"`
    // RulePrefix 是应用到限流器中的规则前缀,
    // 最终的 key 是 RulePrefix + value
    RulePrefix string `json:"rule_prefix"`
}

type HybridRateLimiter struct{}

func (p *HybridRateLimiter) Name() string {
	return "hybrid-rate-limiter"
}

func (p *HybridRateLimiter) ParseConf(in []byte) (interface{}, error) {
	conf := PluginConf{}
	if err := json.Unmarshal(in, &conf); err != nil {
		return nil, fmt.Errorf("failed to parse plugin config: %w", err)
	}
    // 基础校验
    if conf.KeyType == "" || conf.RulePrefix == "" {
        return nil, fmt.Errorf("key_type and rule_prefix are required")
    }
    if (conf.KeyType == "header" || conf.KeyType == "arg") && conf.KeyName == "" {
        return nil, fmt.Errorf("key_name is required for header or arg type")
    }
	return conf, nil
}

func (p *HybridRateLimiter) Filter(conf interface{}, w plugin.ResponseWriter, r plugin.Request) {
	pluginConf := conf.(PluginConf)
	
	var keyValue string
	var err error

	switch pluginConf.KeyType {
	case "remote_addr":
		keyValue, err = r.GetVar("remote_addr")
	case "header":
		keyValue = r.Header().Get(pluginConf.KeyName)
	case "arg":
		keyValue = r.Arg(pluginConf.KeyName)
	default:
		log.Printf("unsupported key_type: %s", pluginConf.KeyType)
		w.WriteHeader(500)
		w.Write([]byte("Internal Server Error: Invalid rate limit config"))
		return
	}

	if err != nil {
		log.Printf("failed to get key value: %v", err)
		w.WriteHeader(500)
		w.Write([]byte("Internal Server Error: Failed to get rate limit key"))
		return
	}
    
    if keyValue == "" {
        // 如果 key 不存在,可以选择放行或拒绝。这里我们选择放行。
        return
    }

    // 构造最终的 key
    finalKey := pluginConf.RulePrefix + keyValue
	cKey := C.CString(finalKey)
	defer C.free(unsafe.Pointer(cKey))

	// === 核心调用:通过 CGO 调用 C++ 函数 ===
	allowed := C.RateLimiter_consume(limiter, cKey)

	if !bool(allowed) {
		w.WriteHeader(429) // Too Many Requests
		w.Write([]byte("Too Many Requests"))
		return
	}

	// 令牌充足,放行请求
}

func main() {
	cfg := runner.RunnerConfig{}
	if err := runner.Run(cfg, &HybridRateLimiter{}); err != nil {
		log.Fatalf("failed to run go plugin runner: %s", err)
	}
    // 在程序退出时清理 C++ 对象,但在 runner.Run 是阻塞的,所以这需要信号处理
    // defer C.RateLimiter_delete(limiter)
}

关键设计点:

  • CGO 注释: /* ... */ import "C" 块是 CGO 的核心,它告诉 Go 编译器如何链接 C/C++ 代码。LDFLAGS: -lstdc++ 确保链接到 C++ 标准库。
  • 全局实例: limiter 是一个全局变量,它持有了 C++ RateLimiter 对象的指针。这保证了所有请求都作用于同一个限流状态机。
  • 内存管理: C.CString 将 Go string 转换为 C string,它在 C 的堆上分配内存。我们必须使用 defer C.free(unsafe.Pointer(cKey)) 来确保内存被释放,防止内存泄漏。这是 CGO 编程中最容易出错的地方。
  • 动态配置: 生产级的 init 函数会启动一个 goroutine,通过 etcd client 监听 APISIX 配置的变化。当检测到与本插件相关的规则变更时,调用 C.RateLimiter_update_ruleC.RateLimiter_remove_rule 来实时更新 C++ 层的状态。

3. 管理端:Ant Design + Relay

管理端的核心是提供一个清晰、无刷新的界面来管理限流规则。Relay 的数据驱动和 GraphQL 的强类型特性非常适合构建这类复杂的表单和列表页面。

我们不会展示完整的前端代码,但可以定义其核心——GraphQL Schema。

schema.graphql:

type RateLimitRule {
    id: ID!
    # 规则的唯一标识,例如 "user-limit", "ip-limit"
    keyPrefix: String!
    # 每秒生成的令牌数
    rate: Float!
    # 桶的最大容量
    burst: Float!
    # 创建时间
    createdAt: String!
}

type Query {
    # 获取所有限流规则
    allRateLimitRules: [RateLimitRule!]!
}

input CreateRateLimitRuleInput {
    keyPrefix: String!
    rate: Float!
    burst: Float!
}

input UpdateRateLimitRuleInput {
    keyPrefix: String!
    rate: Float
    burst: Float
}

type Mutation {
    # 创建新的限流规则
    createRateLimitRule(input: CreateRateLimitRuleInput!): RateLimitRule
    # 更新已有的限流规则
    updateRateLimitRule(input: UpdateRateLimitRuleInput!): RateLimitRule
    # 删除限流规则
    deleteRateLimitRule(keyPrefix: String!): Boolean
}

工作流程:

  1. UI 界面: 使用 Ant Design 的 FormTable 组件。Table 通过 Relay 的 usePaginationFragment 来展示规则列表。点击“新建”或“编辑”按钮会弹出 Modal,内部是 Form
  2. 数据交互:
    • 当用户提交表单时,React 组件调用 Relay 的 useMutation hook。
    • 该 hook 发送一个 createRateLimitRuleupdateRateLimitRule 的 GraphQL mutation 到后端的 Go 管理服务。
  3. 后端管理服务:
    • 这是一个独立的 Go 服务,实现了上述 GraphQL schema。
    • 接收到 mutation 后,它会将规则序列化(例如为 JSON),然后写入到 etcd 的一个特定前缀下(例如 /plugins/ratelimit/rules/user-limit)。
  4. 插件侧感知:
    • 运行在 go-plugin-runner 中的 Go 插件,会持续 watch etcd 中 /plugins/ratelimit/rules/ 路径下的变化。
    • 收到 etcd 的 watch 事件后(创建、更新、删除),插件会解析规则内容,并调用相应的 CGO 函数(RateLimiter_update_rule, RateLimiter_remove_rule)来更新 C++ 核心的状态。

这个闭环实现了从 UI 到网关数据平面的实时配置下发。

架构的扩展性与局限性

扩展性:

  • 更多算法: C++ 核心可以轻松替换或增加其他限流算法,如滑动窗口、固定窗口等,只需通过 CGO 暴露新接口。
  • 更复杂的 Key: Go 插件层可以实现更复杂的 key 提取逻辑,例如从 JWT token 中提取 sub 字段,或者组合多个请求参数作为 key。
  • Metrics: 可以在 C++ 层暴露更多的原子计数器(如总请求数、拒绝数),并通过 CGO 接口让 Go 插件定期拉取,然后通过 APISIX 的 prometheus 插件暴露出去,实现精细化监控。

局限性:

  • 构建复杂度: 引入 C++ 意味着 CI/CD 流水线必须包含 C++ 编译器和相应的构建工具链,增加了环境配置的复杂性。
  • CGO 边界成本: 尽管 CGO 调用比 RPC 快得多,但它并非零成本。频繁地在 Go 和 C 之间传递大量数据会产生开销。设计接口时应尽量保持数据结构的简单。
  • 内存安全风险: C++ 层的内存管理需要开发者自己负责。任何疏忽都可能导致内存泄漏或段错误,从而使整个 APISIX worker 进程崩溃。这要求开发人员具备扎实的 C++ 功底。
  • 单点状态: 当前实现中,每个 APISIX worker 进程内的 go-plugin-runner 都有一个独立的 C++ 限流器实例。这意味着限流是单机维度的。要实现全局限流,C++ 核心需要改造为连接一个外部的集中式存储(如 Redis),但这会重新引入网络延迟,可能会抵消部分本地计算的性能优势,需要重新进行性能评估和权衡。

  目录