基于 ArangoDB Raft 集群为 Tyk 构建一个强一致性的动态配置中心


在管理大规模 Tyk API 网关集群时,我们遇到了一个棘手的问题:动态配置的原子性和一致性。Tyk 默认使用 Redis 存储 API 定义、安全策略等,这在大多数场景下性能优异。但当我们需要实现复杂的、依赖多个配置项原子更新的动态路由、插件注入或 A/B 测试策略时,依赖多个 SET 命令的 Redis 方案就暴露了其脆弱性。一个典型的场景是:更新一条路由规则,同时必须原子地更新与之关联的自定义插件配置。如果其中一步失败,整个网关的状态就会进入一个不确定的中间态,这在生产环境中是不可接受的。

我们需要一个存储后端,它不仅要高可用,更要能提供跨文档的事务保证。我们的初步构想是构建一个独立的配置中心微服务,Tyk 通过一个轻量级 Go 插件与之交互。这个配置中心的核心,必须是一个支持 Raft 协议、能保证线性化写入的数据库。

经过评估,我们排除了 etcd 和 Consul。虽然它们是分布式键值存储的黄金标准,非常适合服务发现,但它们的查询能力和数据模型过于简单,无法满足我们未来可能需要的更复杂的配置关系查询(例如,查询所有使用了某个上游服务的策略)。最终,我们选择了 ArangoDB 集群。ArangoDB 的 Agency(其集群管理层)基于 Raft 协议实现了强一致性,并且其数据库本身支持跨集合的 JavaScript Stream Transactions,这为我们实现配置的原子更新提供了完美的底层能力。

架构设计与环境搭建

我们的目标是搭建一个三节点的 ArangoDB 集群,在其之上构建一个 Go 语言的配置服务,该服务提供 RESTful API 用于管理配置。Tyk 网关中的自定义 Go 插件将从这个服务拉取配置。

graph TD
    subgraph "Tyk API Gateway Cluster"
        Tyk1[Tyk Gateway 1] --> MW1[Go Middleware]
        Tyk2[Tyk Gateway 2] --> MW2[Go Middleware]
        Tyk3[Tyk Gateway 3] --> MW3[Go Middleware]
    end

    subgraph "Configuration Service (Go)"
        direction LR
        API[REST API Layer] --> Logic[Business Logic] --> DB[DB Driver]
    end

    subgraph "ArangoDB Cluster (Raft-based Consistency)"
        Coordinator1[Coordinator 1]
        Coordinator2[Coordinator 2]
        DBServer1[DBServer 1]
        DBServer2[DBServer 2]
        DBServer3[DBServer 3]
        
        subgraph "Agency (Raft Consensus)"
            Agent1[Agent 1]
            Agent2[Agent 2]
            Agent3[Agent 3]
            Agent1 <--> Agent2
            Agent2 <--> Agent3
            Agent1 <--> Agent3
        end
    end

    MW1 --> API
    MW2 --> API
    MW3 --> API

    DB --> Coordinator1
    DB --> Coordinator2

    Coordinator1 --> DBServer1
    Coordinator1 --> DBServer2
    Coordinator1 --> DBServer3
    Coordinator2 --> DBServer1
    Coordinator2 --> DBServer2
    Coordinator2 --> DBServer3

    DBServer1 <--> DBServer2
    DBServer2 <--> DBServer3
    DBServer1 <--> DBServer3

第一步是使用 Docker Compose 启动一个本地的 ArangoDB 集群。这里的关键是使用 arangodb/arangodb 镜像并传入正确的启动参数来初始化集群。

docker-compose.yml

version: '3.8'

services:
  arangodb:
    image: arangodb:3.11
    ports:
      - "8529:8529"
    environment:
      # 必须设置root密码,否则集群无法安全启动
      - ARANGO_ROOT_PASSWORD=mysecretpassword
    volumes:
      - arangodb_data:/var/lib/arangodb3
      - arangodb_apps_data:/var/lib/arangodb3-apps
    command: >
      arangodb 
      --server.endpoint=tcp://0.0.0.0:8529 
      --starter.local 
      --starter.data-dir=/data

volumes:
  arangodb_data:
  arangodb_apps_data:

这个配置使用 ArangoDB Starter 快速启动一个本地集群。在生产环境中,我们会将每个 Agent、Coordinator 和 DBServer 分别部署到独立的节点上。启动后,我们可以通过 http://localhost:8529 访问 ArangoDB 的 Web UI。

强一致性配置服务的 Go 实现

我们的配置服务需要处理两种核心数据:routesplugins。一个 route 可以关联多个 plugin 配置。关键操作是原子地更新一个 route 及其所有关联的 plugin

首先,定义数据结构:
model/config.go

package model

import "time"

// RouteConfig 代表一个路由定义
type RouteConfig struct {
	Key         string   `json:"_key,omitempty"` // ArangoDB document key
	ID          string   `json:"id"`             // 业务ID,例如 API ID
	Path        string   `json:"path"`
	UpstreamURL string   `json:"upstream_url"`
	PluginKeys  []string `json:"plugin_keys"`    // 关联的插件配置的 _key
	Version     int      `json:"version"`
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
}

// PluginConfig 代表一个插件的配置
type PluginConfig struct {
	Key       string                 `json:"_key,omitempty"`
	ID        string                 `json:"id"`
	Name      string                 `json:"name"`
	Config    map[string]interface{} `json:"config"`
	Version   int                    `json:"version"`
	CreatedAt time.Time              `json:"created_at"`
	UpdatedAt time.Time              `json:"updated_at"`
}

// AtomicUpdateRequest 封装了一个原子更新请求
type AtomicUpdateRequest struct {
	Route   RouteConfig    `json:"route"`
	Plugins []PluginConfig `json:"plugins"`
}

接下来是核心的服务层代码,特别是数据库交互部分。我们将使用官方的 arangodb-go-driver

db/arangodb.go

package db

import (
	"context"
	"crypto/tls"
	"encoding/json"
	"errors"
	"log"
	"time"

	driver "github.com/arangodb/go-driver"
	"github.comcom/arangodb/go-driver/http"
	"github.com/your-org/tyk-config-center/model"
)

var (
	db       driver.Database
	routesCol driver.Collection
	pluginsCol driver.Collection
)

// InitDB 初始化数据库连接和集合
func InitDB() {
	// 在生产中,endpoints 应该是多个协调器的地址列表
	endpoints := []string{"http://localhost:8529"}
	conn, err := http.NewConnection(http.ConnectionConfig{
		Endpoints: endpoints,
		TLSConfig: &tls.Config{InsecureSkipVerify: true}, // 生产环境应配置证书
	})
	if err != nil {
		log.Fatalf("Failed to create HTTP connection: %v", err)
	}

	client, err := driver.NewClient(driver.ClientConfig{
		Connection:     conn,
		Authentication: driver.BasicAuthentication("root", "mysecretpassword"),
	})
	if err != nil {
		log.Fatalf("Failed to create client: %v", err)
	}

	db, err = client.Database(context.Background(), "_system")
	if err != nil {
		log.Fatalf("Failed to open database: %v", err)
	}

	// 确保集合存在
	routesCol, err = ensureCollection(db, "routes")
	if err != nil {
		log.Fatalf("Failed to ensure 'routes' collection: %v", err)
	}
	pluginsCol, err = ensureCollection(db, "plugins")
	if err != nil {
		log.Fatalf("Failed to ensure 'plugins' collection: %v", err)
	}
}

func ensureCollection(db driver.Database, name string) (driver.Collection, error) {
	ctx := context.Background()
	exists, err := db.CollectionExists(ctx, name)
	if err != nil {
		return nil, err
	}
	if exists {
		return db.Collection(ctx, name)
	}
	return db.CreateCollection(ctx, name, nil)
}

// UpdateConfigAtomically 是这个服务的核心。它使用 ArangoDB Stream Transaction
// 来保证对 routes 和 plugins 集合的写入是原子性的。
func UpdateConfigAtomically(ctx context.Context, req model.AtomicUpdateRequest) error {
	// JS 事务代码是实现原子性的关键
	// 这里的 JS 会在 ArangoDB 的一个 V8 上下文中执行
	jsTransaction := `
		function (params) {
			const db = require('@arangodb').db;
			const routesCol = db._collection(params.collections.write[0]);
			const pluginsCol = db._collection(params.collections.write[1]);
			const now = new Date().toISOString();

			// 1. 处理插件:要么插入新插件,要么更新现有插件
			const pluginKeys = [];
			for (const plugin of params.plugins) {
				plugin.updated_at = now;
				let doc;
				if (plugin._key) {
					// 更新现有插件
					doc = pluginsCol.update(plugin._key, {
						name: plugin.name,
						config: plugin.config,
						version: { _old: plugin.version - 1, _new: plugin.version },
						updated_at: now
					}, { returnNew: true, overwrite: false });
				} else {
					// 插入新插件
					plugin.created_at = now;
					doc = pluginsCol.insert(plugin, { returnNew: true });
				}
				pluginKeys.push(doc._key);
			}

			// 2. 处理路由:关联所有插件的key,然后插入或更新
			const route = params.route;
			route.plugin_keys = pluginKeys;
			route.updated_at = now;

			if (route._key) {
				routesCol.update(route._key, {
					path: route.path,
					upstream_url: route.upstream_url,
					plugin_keys: pluginKeys,
					version: { _old: route.version - 1, _new: route.version },
					updated_at: now
				});
			} else {
				route.created_at = now;
				routesCol.insert(route);
			}

			// 事务成功,返回结果
			return { success: true, routeKey: route._key, pluginKeys: pluginKeys };
		}
	`
	
	// ArangoDB 的事务 API 要求传入参数
	params := map[string]interface{}{
		"route":   req.Route,
		"plugins": req.Plugins,
	}

	// 定义哪些集合参与事务,以及是读操作还是写操作
	// 这有助于 ArangoDB 进行锁优化
	options := &driver.TransactionOptions{
		MaxTransactionSize: 10 * 1024 * 1024, // 10MB
		Params:             params,
		WriteCollections:   []string{"routes", "plugins"},
		ReadCollections:    []string{}, // 如果需要读,也在这里声明
	}

	// 执行事务
	_, err := db.Transaction(ctx, jsTransaction, options)
	if err != nil {
		// 这里需要处理特定的错误,例如版本冲突
		if driver.IsArangoErrorWithCode(err, 1200) { // 1200 - write-write conflict
			return errors.New("optimistic lock failed: version conflict")
		}
		return err
	}

	return nil
}

// GetRouteWithPlugins 用于获取一个完整的路由及其关联的插件配置
func GetRouteWithPlugins(ctx context.Context, routeID string) (*model.RouteConfig, []model.PluginConfig, error) {
    // AQL 查询可以高效地将路由和插件一次性取回
    query := `
        FOR r IN routes
            FILTER r.id == @routeID
            LIMIT 1
            LET plugins = (
                FOR p_key IN r.plugin_keys
                    LET p = DOCUMENT(CONCAT("plugins/", p_key))
                    RETURN p
            )
            RETURN { route: r, plugins: plugins }
    `
    bindVars := map[string]interface{}{
        "routeID": routeID,
    }
    
    cursor, err := db.Query(ctx, query, bindVars)
    if err != nil {
        return nil, nil, err
    }
    defer cursor.Close()
    
    var result struct {
        Route   model.RouteConfig    `json:"route"`
        Plugins []model.PluginConfig `json:"plugins"`
    }
    
    if _, err := cursor.ReadDocument(ctx, &result); driver.IsNoMoreDocuments(err) {
        return nil, nil, errors.New("route not found")
    } else if err != nil {
        return nil, nil, err
    }
    
    return &result.Route, result.Plugins, nil
}

UpdateConfigAtomically 函数中,我们没有使用 Go Driver 提供的事务构建器,而是直接编写 JavaScript 事务。这是一个重要的权衡:JS 事务提供了最大的灵活性和性能,因为它在数据库服务器端一次性执行,避免了多次网络往返。但缺点是业务逻辑部分耦合进了字符串中,不易维护和测试。在真实项目中,我们会将这些 JS 脚本作为资源文件管理。

注意,我们在更新操作中使用了 version 字段,并试图执行 _old 值的比较,这是实现乐观锁的一种方式。如果客户端提交的 version 与数据库中的 _old 不匹配,ArangoDB 的 update 操作会失败,从而防止并发修改导致的数据覆盖。

Tyk 自定义插件的集成

Tyk 的 Go 插件系统允许我们在请求生命周期的不同阶段注入自定义逻辑。我们将创建一个中间件,它在启动时从配置中心拉取所有路由信息,并定期刷新。

一个简化的插件示例如下:
tyk-plugin/main.go

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"sync"
	"time"

	"github.com/TykTechnologies/tyk/coprocess"
	"github.com/TykTechnologies/tyk/gateway"
)

// 定义配置在内存中的结构
type CachedConfig struct {
	// 使用 Path 作为 key,方便快速查找
	Routes map[string]FullRouteInfo
}

type FullRouteInfo struct {
	// ... 包含路由和插件的所有信息 ...
}

var (
	configCache = &CachedConfig{Routes: make(map[string]FullRouteInfo)}
	cacheLock   = &sync.RWMutex{}
	// 生产中应从环境变量或配置文件读取
	configServiceURL = "http://config-service:8080/api/v1/routes"
)

// MyPreMiddleware 是一个在请求到达上游之前的钩子
func MyPreMiddleware(rw http.ResponseWriter, r *http.Request) {
	// 从请求上下文中获取 API 定义
	apiSpec := gateway.GetSpecFromContext(r.Context())
	if apiSpec == nil {
		return
	}
	
	// 实际逻辑会更复杂,这里仅为演示
	log.Printf("Handling request for API: %s", apiSpec.Name)
	
	cacheLock.RLock()
	// 根据请求路径查找动态配置
	routeInfo, ok := configCache.Routes[r.URL.Path]
	cacheLock.RUnlock()

	if ok {
		// 基于拉取到的配置执行动态逻辑,例如修改请求头
		// r.Header.Set("X-Dynamic-Data", routeInfo.SomeValue)
	}
}

// init 在插件加载时调用
func init() {
	log.Println("Initializing dynamic config loader plugin")
	// 立即加载一次
	go fetchAllConfigs()

	// 启动一个 Ticker 定期刷新配置
	// 实际项目中,这里应该增加 jitter 来避免惊群效应
	ticker := time.NewTicker(30 * time.Second)
	go func() {
		for range ticker.C {
			fetchAllConfigs()
		}
	}()
}

// fetchAllConfigs 从配置中心拉取全量配置
func fetchAllConfigs() {
	log.Println("Fetching all configs from service...")
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	req, _ := http.NewRequestWithContext(ctx, "GET", configServiceURL, nil)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Printf("Error fetching configs: %v", err)
		return
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		log.Printf("Config service returned non-200 status: %d", resp.StatusCode)
		return
	}

	var newRoutes map[string]FullRouteInfo // 假设 API 返回这种格式
	if err := json.NewDecoder(resp.Body).Decode(&newRoutes); err != nil {
		log.Printf("Error decoding configs: %v", err)
		return
	}

	cacheLock.Lock()
	configCache.Routes = newRoutes
	cacheLock.Unlock()

	log.Println("Successfully refreshed configs.")
}

func main() {} // Go 插件需要一个空的 main 函数

这个插件实现了一个简单的轮询拉取机制。一个常见的坑是,当 Tyk 集群规模很大时,所有节点在同一时间请求配置中心会导致流量高峰。解决方案是引入随机延迟(Jitter)或使用更高级的推送机制,如 WebSocket 或 ArangoDB Streams API。

使用 Vitest 测试客户端交互逻辑

我们的 Go 服务是后端,但通常会有一个前端管理界面或一个提供给其他团队的客户端库来与之交互。即使后端是 Go,前端或客户端库通常是 TypeScript/JavaScript。使用 Vitest 来测试这个客户端库的逻辑是至关重要的。

假设我们有一个 TypeScript 客户端库 config-client.ts
config-client.ts

import axios from 'axios';

// 与 Go 服务中的 model 对应
export interface RouteConfig {
  _key?: string;
  id: string;
  path: string;
  upstream_url: string;
  plugin_keys: string[];
  version: number;
}
export interface PluginConfig {
  _key?: string;
  id: string;
  name: string;
  config: Record<string, unknown>;
  version: number;
}
export interface AtomicUpdateRequest {
  route: RouteConfig;
  plugins: PluginConfig[];
}

export class ConfigServiceClient {
  private baseURL: string;

  constructor(baseURL: string) {
    this.baseURL = baseURL;
  }

  async updateConfig(request: AtomicUpdateRequest): Promise<{ success: boolean }> {
    if (!request.route.id || !request.route.path) {
      throw new Error("Route ID and Path are required.");
    }
    // 客户端进行一些基础校验
    if (request.route.version < 1) {
      throw new Error("Version must be a positive integer.");
    }

    try {
      const response = await axios.post(`${this.baseURL}/config`, request, {
        headers: { 'Content-Type': 'application/json' },
      });
      return { success: response.status === 200 };
    } catch (error) {
      if (axios.isAxiosError(error) && error.response) {
        // 将后端的错误信息传递出去
        throw new Error(`API Error: ${error.response.status} ${error.response.data?.error || 'Unknown error'}`);
      }
      throw new Error('Network or unexpected error occurred.');
    }
  }
}

现在,我们用 Vitest 和 msw (Mock Service Worker) 来为这个客户端编写单元测试。msw 可以在网络层面拦截请求,让我们无需启动真实的 Go 服务就能进行测试。

config-client.test.ts

import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { ConfigServiceClient, AtomicUpdateRequest } from './config-client';

// 1. 设置 MSW 服务器来拦截 API 请求
const server = setupServer(
  rest.post('http://fake-api.com/config', async (req, res, ctx) => {
    const body = await req.json<AtomicUpdateRequest>();
    
    // 模拟后端逻辑:例如,如果版本号是 99,就模拟一个冲突
    if (body.route.version === 99) {
      return res(
        ctx.status(409), // Conflict
        ctx.json({ error: 'Optimistic lock failed: version conflict' })
      );
    }
    
    // 模拟成功
    return res(ctx.status(200), ctx.json({ success: true }));
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());


describe('ConfigServiceClient', () => {
  const client = new ConfigServiceClient('http://fake-api.com');

  it('should send a valid atomic update request successfully', async () => {
    const request: AtomicUpdateRequest = {
      route: { id: 'api-1', path: '/test', upstream_url: 'http://upstream', plugin_keys: [], version: 1 },
      plugins: [{ id: 'plugin-1', name: 'cors', config: { origin: '*' }, version: 1 }],
    };
    
    const result = await client.updateConfig(request);
    expect(result.success).toBe(true);
  });
  
  it('should throw an error for invalid input before sending request', async () => {
    const invalidRequest: AtomicUpdateRequest = {
      // @ts-ignore - 测试无效输入
      route: { path: '/test', version: 0 }, 
      plugins: [],
    };
    
    await expect(client.updateConfig(invalidRequest)).rejects.toThrow('Route ID and Path are required.');
    
    const invalidVersionRequest: AtomicUpdateRequest = {
      route: { id: 'api-1', path: '/test', upstream_url: 'http://upstream', plugin_keys: [], version: 0 },
      plugins: [],
    };
    
    await expect(client.updateConfig(invalidVersionRequest)).rejects.toThrow('Version must be a positive integer.');
  });
  
  it('should handle API errors gracefully', async () => {
    const conflictRequest: AtomicUpdateRequest = {
      route: { id: 'api-2', path: '/conflict', upstream_url: 'http://upstream', plugin_keys: [], version: 99 },
      plugins: [],
    };
    
    await expect(client.updateConfig(conflictRequest)).rejects.toThrow(
      'API Error: 409 Optimistic lock failed: version conflict'
    );
  });
  
  it('should handle network errors', async () => {
    server.use(
      rest.post('http://fake-api.com/config', (req, res) => res.networkError('Failed to connect'))
    );
    
    const request: AtomicUpdateRequest = {
      route: { id: 'api-3', path: '/network-error', upstream_url: 'http://upstream', plugin_keys: [], version: 1 },
      plugins: [],
    };
    
    await expect(client.updateConfig(request)).rejects.toThrow('Network or unexpected error occurred.');
  });
});

这组测试验证了客户端的校验逻辑、成功路径、以及对后端特定错误(如 409 冲突)和网络错误的正确处理。这确保了即使在与复杂的后端系统集成时,客户端代码的健壮性也能得到保障。

局限性与未来方向

这套方案解决了我们最初面临的原子性配置更新问题,并为未来更复杂的配置管理打下了基础。然而,它并非没有局限性。

首先,当前 Tyk 插件的配置更新机制是基于轮询的。在高频率变更的场景下,这会带来延迟,并对配置中心产生不必要的压力。一个更好的演进方向是采用推送模型。可以利用 ArangoDB 3.10 之后引入的 Streams API,或者集成一个消息队列(如 NATS),当配置变更时,由配置中心主动通知所有 Tyk 节点进行更新。

其次,我们的 Go 服务目前是单点。虽然 ArangoDB 集群是高可用的,但配置服务本身需要部署多个实例并进行负载均衡才能实现高可用。

最后,权限控制尚未实现。在生产环境中,必须为配置中心的 API 添加精细的认证和授权机制,确保只有授权的用户或服务才能修改关键的网关配置。这可以通过集成 OAuth2/OIDC 或使用 Tyk 自身的安全策略来完成。


  目录