本文引用的文件
本方案为 ai-server 提供多节点扩展与粘性负载均衡。核心约束是:同一用户的请求必须始终路由到同一个 ai-server 节点,避免跨节点访问本地磁盘、附件、DuckDB 案件数据以及会话状态不一致。网关层不实现传统意义上的“自动故障转移”,而是通过“用户 → 节点”绑定 + 健康检查 + 登录绑定机制,在绑定节点不可用时返回 503,由运维或上层重试控制切换。
代码位于两个主要子模块中:
sql:定义网关使用的 PostgreSQL 表 gateway_service、gateway_node、gateway_user_route,作为服务注册与粘性绑定的事实来源。
graph TB
subgraph "网关层 ai-gateway"
LRF["LoginRequestFilter<br/>解析登录用户名"]
SRF["StickyRouteFilter<br/>粘性路由决策与 URL 重写"]
LResF["LoginResponseFilter<br/>登录成功后绑定节点并缓存 token"]
URS["UserRouteService<br/>用户路由 Redis+PG 读写"]
URRepo["UserRouteRepository<br/>JDBC 访问 gateway_user_route"]
TR["TokenResolver<br/>x-token → userId"]
NSel["NodeSelector<br/>匿名/新用户轮询"]
Reg["GatewayRegistry<br/>服务/节点内存快照"]
HReg["NodeHealthRegistry<br/>节点 UP/DOWN 状态"]
HC["HealthChecker<br/>定时探活"]
end
subgraph "数据存储"
PG["PostgreSQL<br/>gateway_service / gateway_node / gateway_user_route"]
Redis["Redis<br/>token 缓存 / 路由缓存"]
end
subgraph "后端节点"
A1["ai-server 节点 1"]
A2["ai-server 节点 2"]
An["ai-server 节点 n"]
end
LRF --> SRF
SRF --> URS
SRF --> TR
SRF --> NSel
LResF --> URS
LResF --> TR
URS --> URRepo
URS --> Redis
TR --> Redis
TR -->|"sa-token"| Redis
Reg --> PG
HC --> Reg
HC --> HReg
SRF --> Reg
SRF --> HReg
SRF --> A1
SRF --> A2
SRF --> An
图示来源
章节来源
章节来源
粘性路由不是简单的负载均衡,而是“用户亲和 + 有限可用性”的组合:
健康检查只判断进程存活,不深入检查 PG/Redis/ES;业务侧应保证单节点内本地资源一致。
sequenceDiagram
participant Client as "客户端"
participant GW as "ai-gateway"
participant AuthSrv as "ai-server 认证接口"
participant DB as "PostgreSQL"
participant R as "Redis"
Client->>GW: "POST {pathPrefix}{loginPath}"
GW->>GW: "LoginRequestFilter 解析 username"
GW->>GW: "StickyRouteFilter 决定节点"
GW->>AuthSrv: "转发登录请求"
AuthSrv-->>GW: "Result<LoginVO>"
GW->>GW: "LoginResponseFilter 解析成功响应"
GW->>DB: "bindIfAbsent(serviceId, userId, username, nodeId)"
GW->>R: "cacheToken(serviceId, token, userId)"
GW-->>Client: "透传登录响应 + X-Gateway-Node"
图示来源
StickyRouteFilter 是粘性路由的核心。其职责包括:
最终校验选定节点是否存在、启用且健康;通过后重写 GATEWAY_REQUEST_URL_ATTR,并添加 X-Gateway-Node 响应头。
flowchart TD
Start(["进入 StickyRouteFilter"]) --> GetRoute["获取 Route 与 serviceId"]
GetRoute --> IsLogin{"是否登录请求?"}
IsLogin -->|是| DecideLogin["按 username 查找绑定<br/>未绑定则轮询健康节点"]
IsLogin -->|否| ResolveToken["从 x-token 解析 userId"]
ResolveToken --> HasUserId{"是否解析出 userId?"}
HasUserId -->|否| Anonymous["匿名轮询健康节点"]
HasUserId -->|是| FindBound["查找用户绑定节点"]
FindBound --> BoundExists{"是否有绑定?"}
BoundExists -->|是| UseBound["使用绑定节点"]
BoundExists -->|否| DefaultNode{"service 是否配置默认节点?"}
DefaultNode -->|是| BindDefault["bindIfAbsent(userId, defaultNodeId)"]
DefaultNode -->|否| ErrorNoBind["抛出 503:用户未绑定节点"]
BindDefault --> UseBound
DecideLogin --> SelectNode["轮询健康节点"]
SelectNode --> UseBound
Anonymous --> UseBound
UseBound --> ValidateNode["校验节点存在/启用/健康"]
ValidateNode --> Valid{"节点可用?"}
Valid -->|否| ErrorDown["抛出 503:绑定节点不可用"]
Valid -->|是| Rewrite["重写目标 URI + 设置 X-Gateway-Node"]
Rewrite --> End(["继续下游过滤链"])
图示来源
关键设计点:
章节来源
LoginRequestFilter 只在配置的 loginFullPath 上生效,流程如下:
重建请求体,确保下游仍能消费原始 body。
flowchart TD
Enter(["进入 LoginRequestFilter"]) --> MatchLogin["匹配 pathPrefix + loginPath"]
MatchLogin -->|不匹配| PassThrough["直接透传"]
MatchLogin -->|匹配| ReadBody["读取请求体(≤64KB)"]
ReadBody --> ParseUsername["解析 JSON 中的 username"]
ParseUsername --> SetAttrs["设置 LOGIN_REQUEST 与 LOGIN_USERNAME"]
SetAttrs --> FixEncoding["Accept-Encoding 改为 identity"]
FixEncoding --> RebuildBody["重建可重复读取的请求体"]
RebuildBody --> Next["进入下游过滤器"]
图示来源
章节来源
LoginResponseFilter 在响应写出阶段拦截登录响应:
无论绑定成功与否,都不修改原始响应体。
sequenceDiagram
participant GW as "ai-gateway"
participant LResF as "LoginResponseFilter"
participant URS as "UserRouteService"
participant TR as "TokenResolver"
participant PG as "PostgreSQL"
participant R as "Redis"
GW->>LResF: "捕获登录响应"
LResF->>LResF: "解析 code=200 + data.token/userId/username"
LResF->>URS: "bindIfAbsent(serviceId, userId, username, chosenNodeId)"
URS->>PG: "INSERT ON CONFLICT DO NOTHING"
PG-->>URS: "生效节点"
URS->>R: "写入路由缓存 key"
LResF->>TR: "cacheToken(serviceId, token, userId)"
TR->>R: "写入 gateway:token:{serviceId}:{token}"
LResF-->>GW: "透传原始响应"
图示来源
章节来源
UserRouteService 提供两类能力:
UserRouteRepository 使用 JdbcTemplate 直接操作 PostgreSQL:
数据库模型由 gateway.sql 定义:
gateway_user_route:用户粘性绑定,支持 user_id 或 username 唯一绑定,含部分唯一索引。
erDiagram
GATEWAY_SERVICE ||--o{ GATEWAY_NODE : "包含"
GATEWAY_SERVICE ||--o{ GATEWAY_USER_ROUTE : "拥有"
GATEWAY_NODE ||--o{ GATEWAY_USER_ROUTE : "被绑定"
GATEWAY_SERVICE {
varchar id PK
varchar name
varchar path_prefix UK
varchar login_path
varchar health_path
varchar default_node_id
smallint enabled
timestamp create_time
timestamp update_time
}
GATEWAY_NODE {
varchar id PK
varchar service_id FK
varchar name
varchar base_url
smallint enabled
varchar remark
timestamp create_time
timestamp update_time
}
GATEWAY_USER_ROUTE {
bigserial id PK
varchar service_id FK
bigint user_id
varchar username
varchar node_id FK
timestamp create_time
timestamp update_time
}
图示来源
章节来源
TokenResolver 负责将 x-token 转为 userId:
解析失败返回空 Mono,由 StickyRouteFilter 按匿名请求处理。
flowchart TD
In(["TokenResolver.resolveUserId"]) --> CheckToken{"token 是否为空?"}
CheckToken -->|是| Empty["返回空"]
CheckToken -->|否| CacheRead["读取 gateway:token:{serviceId}:{token}"]
CacheRead --> Hit{"缓存命中?"}
Hit -->|是| ReturnCache["返回 userId"]
Hit -->|否| SaToken["调用 StpUtil.getLoginIdByToken"]
SaToken --> ParseOk{"解析成功?"}
ParseOk -->|否| Empty
ParseOk -->|是| WriteCache["写入 Redis,TTL 24h"]
WriteCache --> ReturnParsed["返回 userId"]
图示来源
章节来源
NodeSelector 为匿名请求和新用户选择节点:
注意:NodeSelector 不影响已绑定用户;已绑定用户即使 DOWN 也不会被换掉。
章节来源
GatewayRegistry 维护服务与节点的内存快照:
ServiceInfo.matchesPath 支持最长前缀匹配。
classDiagram
class GatewayRegistry {
-List~ServiceInfo~ services
-boolean initialized
+init()
+seedFromYamlIfNeeded()
+reload() boolean
+pollForChanges()
+getServices() List
+getService(id) Optional
+findServiceByPath(rawPath) Optional
}
class ServiceInfo {
+String id
+String name
+String pathPrefix
+String loginPath
+String healthPath
+String defaultNodeId
+boolean enabled
+List~NodeInfo~ nodes
+loginFullPath() String
+matchesPath(rawPath) boolean
+findNode(nodeId) Optional
}
class NodeInfo {
+String id
+String serviceId
+String name
+String baseUrl
+boolean enabled
+String remark
}
GatewayRegistry --> ServiceInfo : "管理"
ServiceInfo --> NodeInfo : "包含"
图示来源
章节来源
HealthChecker 定时对所有启用节点发起 GET {baseUrl}{healthPath}:
NodeHealthRegistry 是每个网关实例的本地健康状态表,不跨实例同步。
章节来源
粘性路由相关组件的依赖关系如下:
graph LR
StickyRouteFilter --> GatewayRegistry
StickyRouteFilter --> NodeHealthRegistry
StickyRouteFilter --> NodeSelector
StickyRouteFilter --> TokenResolver
StickyRouteFilter --> UserRouteService
LoginResponseFilter --> UserRouteService
LoginResponseFilter --> TokenResolver
LoginResponseFilter --> GatewayRegistry
UserRouteService --> UserRouteRepository
UserRouteService --> Redis
TokenResolver --> Redis
HealthChecker --> GatewayRegistry
HealthChecker --> NodeHealthRegistry
GatewayRegistry --> PostgreSQL
UserRouteRepository --> PostgreSQL
图示来源
章节来源
推荐拓扑:
单个或集群式 ai-gateway 做粘性转发,前端只暴露网关入口。
graph TB
FE["前端 / 客户端"]
GW1["ai-gateway 实例 1"]
GW2["ai-gateway 实例 2"]
AS1["ai-server 节点 1"]
AS2["ai-server 节点 2"]
AS3["ai-server 节点 3"]
PG["PostgreSQL / pgvector"]
RD["Redis"]
ES["Elasticsearch"]
N4["Neo4j"]
FE --> GW1
FE --> GW2
GW1 --> AS1
GW1 --> AS2
GW1 --> AS3
GW2 --> AS1
GW2 --> AS2
GW2 --> AS3
AS1 --> PG
AS2 --> PG
AS3 --> PG
AS1 --> RD
AS2 --> RD
AS3 --> RD
AS1 --> ES
AS2 --> ES
AS3 --> ES
AS1 --> N4
AS2 --> N4
AS3 --> N4
该图是概念部署图,不映射单一源码文件,因此不提供图示来源。
[本节为通用扩展建议,不直接分析具体源码行]
章节来源
常见表现:同一用户请求落到不同节点。 排查要点:
章节来源
常见表现:登录后下一次请求路由到不同节点。 排查要点:
章节来源
风险点:
定位方法:
章节来源
风险点:
定位方法:
章节来源
当前实现明确不自动切换:
修复思路:
章节来源
本方案以“用户 → 节点”绑定为核心,结合登录前后处理器、Redis 缓存、PostgreSQL 持久化和健康检查,实现 ai-server 的多节点粘性扩展。其关键约束是:绑定节点不可用时返回 503,不自动切换,以保证本地磁盘和会话一致性。生产扩展时应重点关注 Redis 热点、数据库连接池、DuckDB 内存占用、向量索引重建成本,以及 ES/Neo4j 共享资源的并发压力。治理粘性路由问题应从 token 解析、用户绑定、服务配置、节点健康四个维度逐步定位。