本文引用的文件
本仓库的 ai-gateway 模块实现了一个基于 Spring Cloud Gateway 的粘性路由网关。其设计目标是:用户登录后会话必须固定到同一台 ai-server 节点,因为后端节点上存在本地磁盘上的案件数据、附件或 Agent 记忆等状态。若允许会话在多个节点间漂移,用户可能访问到没有自身业务数据的节点。
网关通过数据库维护服务、节点和用户粘性绑定关系;运行期按登录用户名或 x-token 解析目标节点,重写目标 URL,并在响应头中暴露实际转发到的节点信息。同时提供健康探测、节点 DOWN 时返回 503、跨域控制、统一错误输出和可观测性支持。
ai-gateway 采用分层 + 职责单一的组织方式:
route:粘性路由主流程、登录前后处理器、用户路由缓存、节点选择器、动态路由定义。registry:服务与节点配置注册表、内存快照、健康状态。support:横切能力,包括统一错误处理器、请求标识、IP 白名单、粘性不可用异常、API 结果封装。config:网关属性、WebClient、CORS 等基础设施配置。admin:管理接口(认证、控制器、服务),用于修改数据库中的服务、节点和用户路由。sql/gateway.sql:PostgreSQL 表结构,包含 gateway_service、gateway_node、gateway_user_route。
graph TB
subgraph "网关层"
SCG["Spring Cloud Gateway"]
DefRepo["GatewayRouteDefinitionRepository<br/>动态路由定义"]
LoginReq["LoginRequestFilter<br/>登录预处理"]
Sticky["StickyRouteFilter<br/>粘性路由核心"]
LoginRes["LoginResponseFilter<br/>登录绑定后处理"]
ReqId["RequestIdFilter<br/>X-Request-Id"]
Error["GatewayErrorHandler<br/>统一错误出口"]
end
subgraph "注册与健康"
Registry["GatewayRegistry<br/>服务/节点快照"]
Health["NodeHealthRegistry<br/>节点健康状态"]
Selector["NodeSelector<br/>匿名轮询"]
end
subgraph "存储层"
PG["PostgreSQL<br/>gateway_service / gateway_node / gateway_user_route"]
Redis["Redis<br/>用户路由缓存 / Token 缓存"]
end
subgraph "后端"
AiServer["ai-server 实例<br/>多节点本地状态"]
end
Client["浏览器或客户端"] --> SCG
ReqId --> LoginReq --> Sticky --> LoginRes
DefRepo --> SCG
Registry --> DefRepo
Registry --> Sticky
Health --> Sticky
Health --> Selector
Sticky --> AiServer
LoginReq --> PG
LoginRes --> Redis
Sticky --> Redis
Sticky --> PG
Error --> Client
图表来源
章节来源
| 组件 | 主要职责 | 关键行为 |
|---|---|---|
| StickyRouteFilter | 粘性路由核心过滤器 | 判断登录/普通请求,解析 userId 或 username,选择已绑定节点或默认节点;校验节点启用与健康;重写 GATEWAY_REQUEST_URL_ATTR;追加 X-Gateway-Node |
| LoginRequestFilter | 登录请求预处理 | 匹配配置的登录路径,解析 JSON 体中的 username,设置 LOGIN_REQUEST/LLOGIN_USERNAME 属性,改写 Accept-Encoding,重放请求体 |
| LoginResponseFilter | 登录响应后处理 | 在 NettyWriteResponseFilter 之前捕获响应体,解析成功登录结构,bindIfAbsent 写库并回写 Redis,缓存 token |
| UserRouteService | 用户路由读写与两级缓存 | Redis 热路径 + PostgreSQL 事实源;提供按 userId、username 查询与首次绑定;管理端清理缓存 |
| NodeSelector | 匿名/新用户轮询 | 仅对 enabled 且 isUp 的节点做轮询;单节点直接返回 |
| GatewayRegistry | 服务/节点配置快照 | 启动播种 YAML,加载数据库,定时对账,发布 RefreshRoutesEvent |
| NodeHealthRegistry | 节点健康状态 | UNKNOWN 乐观放行,连续失败阈值标记 DOWN,一次成功恢复 UP |
| GatewayErrorHandler | 统一错误输出 | StickyUnavailableException → 503;上游连接失败/超时 → 502;其他 → 500 |
| RequestIdFilter | 请求标识透传 | 生成或透传 X-Request-Id,写入交换属性与响应头 |
| CorsConfig | 可选网关层跨域 | 默认关闭,避免与后端双份 Access-Control-Allow-Origin 冲突 |
章节来源
网关并不把 Spring Cloud Gateway 的路由 URI 直接指向某个具体 ai-server。动态路由定义只声明路径前缀,URI 使用占位符 http://gateway.invalid;真实目标地址由 StickyRouteFilter 在 RouteToRequestUrlFilter 之后重写 GATEWAY_REQUEST_URL_ATTR。这样既保留 Spring Cloud Gateway 的路由发现机制,又能在运行时决定“哪个 ai-server 实例”。
sequenceDiagram
participant Client as "客户端"
participant Gateway as "网关"
participant LoginReq as "LoginRequestFilter"
participant Sticky as "StickyRouteFilter"
participant SCG as "RouteToRequestUrlFilter / NettyRoutingFilter"
participant Backend as "ai-server 指定节点"
Client->>Gateway: POST /js/a/auth/login {username, password}
Gateway->>LoginReq: 匹配登录路径
LoginReq->>LoginReq: 解析 username<br/>设置 LOGIN_REQUEST / LOGIN_USERNAME
LoginReq->>Sticky: 继续过滤链
Sticky->>Sticky: 按 username 查绑定节点<br/>未绑定则轮询候选节点
Sticky->>SCG: 重写 GATEWAY_REQUEST_URL_ATTR<br/>追加 X-Gateway-Node
SCG->>Backend: 流式转发原始 rawPath + rawQuery
Backend-->>SCG: Result{code, data.token, data.userId, data.username}
SCG-->>Sticky: 响应流
Sticky-->>LoginRes: 继续响应阶段
LoginRes->>LoginRes: 解析成功登录结构<br/>bindIfAbsent 写库 + 缓存 token
LoginRes-->>Client: 原样返回登录响应
图表来源
StickyRouteFilter 是本次文档最重要的组件。它实现了粘性路由的核心规则:
URL 拼接安全:使用字符串拼接 baseUrl + requestUri.getRawPath() + query,再用 URI.create,避免多参 URI 构造器重复编码。
flowchart TD
Start(["进入 StickyRouteFilter"]) --> HasRoute["是否有 GATEWAY_ROUTE_ATTR?"]
HasRoute -->|否| Next["交给下游过滤器"]
HasRoute -->|是| HasServiceMeta["是否有 serviceId 元数据?"]
HasServiceMeta -->|否| Next
HasServiceMeta -->|是| LoadService["从 GatewayRegistry 加载 ServiceInfo"]
LoadService --> Enabled{"服务存在且启用?"}
Enabled -->|否| Throw503["抛出 StickyUnavailableException -> 503"]
Enabled -->|是| IsLogin{"是否为登录请求?"}
IsLogin -->|是| DecideLogin["按 username 查绑定节点<br/>未绑定则轮询候选节点"]
IsLogin -->|否| DecideNormal["按 x-token 解析 userId<br/>未绑定则兜底 defaultNodeId"]
DecideLogin --> SelectNode["确定 nodeId"]
DecideNormal --> SelectNode
SelectNode --> ValidateNode["校验节点存在、启用、健康"]
ValidateNode -->|失败| Throw503
ValidateNode --> Rewrite["重写 GATEWAY_REQUEST_URL_ATTR<br/>追加 X-Gateway-Node"]
Rewrite --> End(["继续交给 NettyRoutingFilter 转发"])
图表来源
章节来源
这两个过滤器构成“登录前标记 + 成功后写库”的协作模式:
如果解析失败、响应体为空、超过大小限制,或者登录不是成功结构,LoginResponseFilter 不会改变登录响应,也不会阻断请求;它只记录日志,让下次可解析的登录再补绑定。
sequenceDiagram
participant LoginReq as "LoginRequestFilter"
participant Sticky as "StickyRouteFilter"
participant Backend as "ai-server"
participant LoginRes as "LoginResponseFilter"
participant DB as "PostgreSQL"
participant Redis as "Redis"
LoginReq->>LoginReq: 解析 username<br/>设置 LOGIN_REQUEST / LOGIN_USERNAME
LoginReq->>Sticky: 继续请求阶段
Sticky->>Backend: 转发登录请求
Backend-->>LoginRes: 返回 Result{code,data}
LoginRes->>LoginRes: 解析成功登录结构
LoginRes->>DB: bindIfAbsent(serviceId, userId, username, chosenNodeId)
LoginRes->>Redis: cacheToken(serviceId, token, userId)
LoginRes-->>Client: 原样返回登录响应
图表来源
章节来源
GatewayRegistry 维护服务与节点的内存快照:
NodeHealthRegistry 是单机内存状态:
Snapshot 方法可用于诊断当前健康视图。
classDiagram
class GatewayRegistry {
+init()
+seedFromYamlIfNeeded()
+reload() boolean
+pollForChanges()
+getServices() List~ServiceInfo~
+getService(id) Optional~ServiceInfo~
+findServiceByPath(rawPath) Optional~ServiceInfo~
}
class ServiceInfo {
+id() String
+name() String
+pathPrefix() String
+loginPath() String
+healthPath() String
+defaultNodeId() String
+enabled() boolean
+nodes() List~NodeInfo~
+loginFullPath() String
+matchesPath(rawPath) boolean
+findNode(nodeId) Optional~NodeInfo~
}
class NodeInfo {
+id() String
+serviceId() String
+name() String
+baseUrl() String
+enabled() boolean
+remark() String
}
class NodeHealthRegistry {
<<record>> Health
+isUp(nodeId) boolean
+recordSuccess(nodeId) void
+recordFailure(nodeId, error, threshold) void
+snapshot() Map~String, Health~
}
GatewayRegistry --> ServiceInfo : "加载快照"
ServiceInfo --> NodeInfo : "包含节点组"
StickyRouteFilter --> GatewayRegistry : "查找服务"
StickyRouteFilter --> NodeHealthRegistry : "检查节点健康"
图表来源
章节来源
下面给出一个更贴近“用户请求经过网关转发到指定 ai-server”的数据流图:
flowchart LR
Browser["浏览器"] --> GW["Spring Cloud Gateway"]
GW --> Def["动态路由<br/>Path=/js/a/**"]
Def --> Attr["StickyRouteFilter<br/>解析 userId/username"]
Attr --> Cache["Redis 用户路由缓存"]
Cache --> DB["PostgreSQL gateway_user_route"]
Attr --> Health["NodeHealthRegistry"]
Attr --> Selector["NodeSelector"]
Attr --> Rewrite["重写 GATEWAY_REQUEST_URL_ATTR"]
Rewrite --> Netty["NettyRoutingFilter"]
Netty --> Server["ai-server 指定节点"]
Server --> Netty
Netty --> GW
GW --> Browser
图表来源
章节来源
| 表 | 作用 | 关键字段 | 约束与语义 |
|---|---|---|---|
| gateway_service | 定义一个对外路径前缀及其后端节点组 | id、name、path_prefix、login_path、health_path、default_node_id、enabled | path_prefix 全局唯一;login_path 可为空表示不做登录粘性;default_node_id 为存量用户兜底节点 |
| gateway_node | 登记一台 ai-server 实例 | id、service_id、name、base_url、enabled、remark | base_url 不含路径;同一 service_id 下 base_url 唯一 |
| gateway_user_route | 用户粘性绑定 | id、service_id、user_id、username、node_id | user_id 与 username 至少一个非空;通过部分唯一索引保证同一服务下 userId 或 username 唯一绑定到一个节点 |
数据库注释明确指出:node_id 是绑定的目标节点;节点不可用时网关返回 503,不自动切换。这与 StickyUnavailableException 的设计一致。
章节来源
StickyRouteFilter 处于路由链路的核心,依赖较多组件;LoginRequestFilter 和 LoginResponseFilter 分别负责登录前后的横切逻辑;GatewayRegistry 和 NodeHealthRegistry 提供运行期配置与健康视图。
graph TD
Sticky["StickyRouteFilter"] --> Registry["GatewayRegistry"]
Sticky --> Health["NodeHealthRegistry"]
Sticky --> Selector["NodeSelector"]
Sticky --> Resolver["TokenResolver"]
Sticky --> UserSvc["UserRouteService"]
LoginReq["LoginRequestFilter"] --> Registry
LoginRes["LoginResponseFilter"] --> UserSvc
LoginRes --> Resolver
LoginRes --> Registry
UserSvc --> Repo["UserRouteRepository"]
UserSvc --> Redis["Redis"]
DefRepo["GatewayRouteDefinitionRepository"] --> Registry
ErrorHandler["GatewayErrorHandler"] --> StickyExc["StickyUnavailableException"]
图表来源
章节来源
gateway:route:user:{serviceId}:{userId} 和 gateway:route:name:{serviceId}:{lower(username)};读失败降级直查数据库,写缓存失败只记日志,不影响正确性。章节来源
CorsConfig 默认关闭,原因是 ai-server 已有全局跨域配置,网关再配一层会产生两个 Access-Control-Allow-Origin,浏览器拒绝整个响应。只有在确实需要网关收口跨域时才启用,并通过 application.yaml 配置 allowedOriginPatterns。启用时会暴露 X-Gateway-Node、Content-Disposition、X-Request-Id 等响应头,并允许无凭证跨域。
章节来源
RequestIdFilter 在所有过滤器之前执行,负责透传或生成 X-Request-Id,并写入 GatewayAttributes.REQUEST_ID。该 ID 贯穿交换属性,被 GatewayErrorHandler 用于错误日志,帮助在多网关实例和多 ai-server 节点之间串联日志。
章节来源
GatewayErrorHandler 是统一错误出口:
章节来源
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 登录后仍 503 | 用户未绑定节点,或服务未配置 defaultNodeId | 查看 gateway_user_route 是否缺失;检查 gateway_service.default_node_id |
| 某用户固定访问某节点但该节点 DOWN | 粘性路由不自动切换 | 正常行为;需手动解绑或调整 default_node_id |
| 新登录用户随机落到不同节点 | 首次登录未绑定,匿名轮询 | 确认 LoginResponseFilter 是否成功解析 Result;检查 Redis 和 gateway_user_route |
| 全部匿名请求 503 | 没有可用节点,或所有节点 DOWN | 检查 NodeHealthRegistry 健康状态、节点 base_url、服务 enabled 状态 |
| 登录后 token 无效 | TokenResolver 未缓存或 token 过期 | 检查 LoginResponseFilter 的 token 缓存逻辑及后端 token 生命周期 |
| 跨域预检失败 | 后端与网关双重 CORS 配置冲突 | 优先使用后端 CorsFilter;如需网关层处理,开启 gateway.cors.enabled |
| 找不到请求上下文 | 缺少 X-Request-Id | 检查客户端是否携带;确认 RequestIdFilter 是否生效 |
| 管理接口变更后路由未刷新 | 多实例对账延迟 | 等待 registry-poll-interval 周期,或确认 reload() 是否发布 RefreshRoutesEvent |
章节来源
该网关通过“数据库配置 + 运行期快照 + Redis 热缓存 + 用户粘性绑定”的组合,实现了用户会话固定到同一 ai-server 节点的目标。StickyRouteFilter 在 Spring Cloud Gateway 内部完成精确的目标 URL 重写,LoginRequestFilter 与 LoginResponseFilter 配合完成登录前标识与登录后绑定,GatewayRegistry 与 NodeHealthRegistry 提供服务注册与健康视图,GatewayErrorHandler 统一输出结构化错误。
最关键的设计决策是:绑定节点不可用时不自动切换。这牺牲了部分可用性,但保证了拥有本地磁盘状态的 ai-server 节点与用户数据的一致性。运维侧应通过合理配置 default_node_id、健康探针、管理接口和监控告警来平衡稳定性与可维护性。