网关与粘性路由架构.md 31 KB

网关与粘性路由架构

本文引用的文件

  • StickyRouteFilter.java
  • LoginRequestFilter.java
  • LoginResponseFilter.java
  • UserRouteService.java
  • NodeSelector.java
  • GatewayAttributes.java
  • GatewayRouteDefinitionRepository.java
  • GatewayRegistry.java
  • NodeHealthRegistry.java
  • ServiceInfo.java
  • NodeInfo.java
  • GatewayErrorHandler.java
  • StickyUnavailableException.java
  • RequestIdFilter.java
  • CorsConfig.java
  • gateway.sql

目录

  1. 引言
  2. 项目结构
  3. 核心组件
  4. 架构总览
  5. 详细组件分析
  6. 依赖关系分析
  7. 性能与缓存特性
  8. 横切关注点:跨域、请求标识与错误处理
  9. 运维排查要点
  10. 结论

引言

本仓库的 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
    

图表来源

  • GatewayRouteDefinitionRepository.java:23-66
  • LoginRequestFilter.java:17-120
  • StickyRouteFilter.java:17-180
  • LoginResponseFilter.java:17-176
  • GatewayRegistry.java:17-116
  • NodeHealthRegistry.java:13-68
  • NodeSelector.java:9-45
  • gateway.sql:1-107

章节来源

  • GatewayRouteDefinitionRepository.java:23-66
  • gateway.sql:1-107

核心组件

组件 主要职责 关键行为
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 冲突

章节来源

  • StickyRouteFilter.java:17-180
  • LoginRequestFilter.java:17-120
  • LoginResponseFilter.java:17-176
  • UserRouteService.java:13-117
  • NodeSelector.java:9-45
  • GatewayRegistry.java:17-116
  • NodeHealthRegistry.java:13-68
  • GatewayErrorHandler.java:13-98
  • RequestIdFilter.java:10-34
  • CorsConfig.java:10-39

架构总览

网关并不把 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: 原样返回登录响应

图表来源

  • LoginRequestFilter.java:41-120
  • StickyRouteFilter.java:49-180
  • LoginResponseFilter.java:41-176
  • GatewayRouteDefinitionRepository.java:45-66

详细组件分析

StickyRouteFilter:粘性路由核心

StickyRouteFilter 是本次文档最重要的组件。它实现了粘性路由的核心规则:

  1. order=10001:排在 RouteToRequestUrlFilter(order=10000)之后。此时 Spring Cloud Gateway 已经算出占位的 GATEWAY_REQUEST_URL_ATTR,StickyRouteFilter 把它替换成“节点 baseUrl + 原始 rawPath + rawQuery”,再由 NettyRoutingFilter 流式转发。
  2. 登录请求与普通请求分支不同:
    • 登录请求:优先查 username 是否已绑定;未绑定时从健康节点轮询出一个“候选节点”,但不立即写库,等待 LoginResponseFilter 确认成功后再绑定。
    • 普通请求:从 x-token 解析 userId;若 userId 有效但未绑定,则尝试 bindToDefaultNode;若无 defaultNodeId,返回 503“用户未绑定节点”;token 解析不出则按匿名请求轮询。
  3. 节点校验严格:即使轮询选到了节点,也会检查 service.findNode(nodeId)、node.enabled() 和 healthRegistry.isUp(nodeId)。任一不满足就抛 StickyUnavailableException。
  4. 响应头 X-Gateway-Node:记录最终转发的节点 id,便于前端、日志和运维排障。
  5. 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 转发"])
    

图表来源

  • StickyRouteFilter.java:49-180

章节来源

  • StickyRouteFilter.java:17-180
  • GatewayAttributes.java:5-31

LoginRequestFilter 与 LoginResponseFilter:登录前标识与登录后绑定

这两个过滤器构成“登录前标记 + 成功后写库”的协作模式:

  • LoginRequestFilter 的 order=9999,先于 StickyRouteFilter。它只对服务定义的 loginFullPath 生效,读取请求体中的 username,设置 LOGIN_REQUEST 和 LOGIN_USERNAME,并把 Accept-Encoding 改为 identity,防止后端 gzip 压缩导致后续无法解析响应。
  • LoginResponseFilter 的 order 是 NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1,也就是比真正写响应体的过滤器更早一层。它会装饰响应体,收集最多 256KB 的字节,然后解析 Result{code=200, data.token, data.userId, data.username}。解析成功后调用 UserRouteService.bindIfAbsent 写入数据库,并通过 TokenResolver.cacheToken 缓存 token。
  • 如果解析失败、响应体为空、超过大小限制,或者登录不是成功结构,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: 原样返回登录响应
    

图表来源

  • LoginRequestFilter.java:41-120
  • LoginResponseFilter.java:41-176
  • UserRouteService.java:69-117

章节来源

  • LoginRequestFilter.java:17-120
  • LoginResponseFilter.java:17-176
  • GatewayAttributes.java:13-23

GatewayRegistry 与 NodeHealthRegistry:服务注册与健康

GatewayRegistry 维护服务与节点的内存快照:

  • 启动时若 gateway_service 表为空,会从 application.yaml 的 gateway.services 播种。
  • 每次 reload 从数据库加载 ServiceInfo 列表,对比旧快照;有变化且已初始化时发布 RefreshRoutesEvent,触发 Spring Cloud Gateway 刷新路由定义。
  • 每 10 秒(可通过配置覆盖)对账一次,保证多实例共享同一个 PostgreSQL 时最终一致。

NodeHealthRegistry 是单机内存状态:

  • UNKNOWN 表示尚未探活,isUp 返回 true,避免刚启动时所有匿名请求 503。
  • 连续失败达到阈值后标记 DOWN;一次成功立刻恢复 UP。
  • 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 : "检查节点健康"
    

图表来源

  • GatewayRegistry.java:17-116
  • ServiceInfo.java:5-32
  • NodeInfo.java:5-13
  • NodeHealthRegistry.java:13-68
  • StickyRouteFilter.java:75-180

章节来源

  • GatewayRegistry.java:17-116
  • NodeHealthRegistry.java:13-68
  • ServiceInfo.java:5-32
  • NodeInfo.java:5-13

用户请求到 ai-server 的数据流图

下面给出一个更贴近“用户请求经过网关转发到指定 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

图表来源

  • GatewayRouteDefinitionRepository.java:45-66
  • StickyRouteFilter.java:75-180
  • UserRouteService.java:25-68
  • NodeSelector.java:23-45
  • NodeHealthRegistry.java:27-43

跨节点切换策略与 503 原因

  • 匿名用户与新用户:没有 userId 或 userId 未绑定时,NodeSelector 在 enabled 且 isUp 的节点间轮询。
  • 已绑定用户:严格按数据库中的 node_id 转发,不做负载均衡切换。
  • 节点 DOWN:StickyRouteFilter 会检查 healthRegistry.isUp(nodeId),DOWN 则抛 StickyUnavailableException,最终由 GatewayErrorHandler 返回 503。这是有意为之:用户数据可能只在当前节点本地存在,自动切换到另一节点会导致数据不一致。
  • 无默认节点:userId 有效但 user_route 表中无记录,且服务未配置 defaultNodeId,也返回 503,提示“用户未绑定节点,请重新登录或联系管理员”。
  • 服务不存在或停用:同样走 503,但消息说明是服务维度问题。

章节来源

  • NodeSelector.java:23-45
  • StickyRouteFilter.java:75-180
  • StickyUnavailableException.java:5-25
  • GatewayErrorHandler.java:35-98

gateway_service / gateway_node / gateway_user_route 表的作用

表 作用 关键字段 约束与语义
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 的设计一致。

章节来源

  • gateway.sql:1-107

依赖关系分析

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"]

图表来源

  • StickyRouteFilter.java:33-180
  • LoginRequestFilter.java:33-120
  • LoginResponseFilter.java:33-176
  • UserRouteService.java:25-117
  • GatewayRouteDefinitionRepository.java:23-66
  • GatewayErrorHandler.java:35-98

章节来源

  • StickyRouteFilter.java:33-180
  • LoginRequestFilter.java:33-120
  • LoginResponseFilter.java:33-176
  • UserRouteService.java:25-117
  • GatewayRouteDefinitionRepository.java:23-66

性能与缓存特性

  • Redis 两级缓存:UserRouteService 先读 Redis,key 形如 gateway:route:user:{serviceId}:{userId} 和 gateway:route:name:{serviceId}:{lower(username)};读失败降级直查数据库,写缓存失败只记日志,不影响正确性。
  • 数据库唯一索引裁决并发:bindIfAbsent 通过 PostgreSQL 的部分唯一索引保证同一服务下 userId 或 username 只绑定到一个节点;LoginResponseFilter 还记录了“并发首次登录命中已有节点”的日志。
  • Token 缓存:LoginResponseFilter 将 token 与 userId 绑定后缓存,避免每次请求都解析远端 token。
  • 匿名轮询 O(1):NodeSelector 使用 ConcurrentHashMap 中每个服务的 AtomicInteger 做模运算轮询,时间复杂度接近 O(1),空间复杂度与活跃服务数线性相关。
  • 健康状态内存级:NodeHealthRegistry 是进程内 ConcurrentHashMap,记录最近失败次数、最后检查时间和错误信息,避免频繁网络探测影响热路径。

章节来源

  • UserRouteService.java:25-117
  • NodeSelector.java:23-45
  • NodeHealthRegistry.java:27-68
  • LoginResponseFilter.java:104-176

横切关注点:跨域、请求标识与错误处理

跨域 CORS

CorsConfig 默认关闭,原因是 ai-server 已有全局跨域配置,网关再配一层会产生两个 Access-Control-Allow-Origin,浏览器拒绝整个响应。只有在确实需要网关收口跨域时才启用,并通过 application.yaml 配置 allowedOriginPatterns。启用时会暴露 X-Gateway-Node、Content-Disposition、X-Request-Id 等响应头,并允许无凭证跨域。

章节来源

  • CorsConfig.java:10-39

请求标识 RequestId

RequestIdFilter 在所有过滤器之前执行,负责透传或生成 X-Request-Id,并写入 GatewayAttributes.REQUEST_ID。该 ID 贯穿交换属性,被 GatewayErrorHandler 用于错误日志,帮助在多网关实例和多 ai-server 节点之间串联日志。

章节来源

  • RequestIdFilter.java:10-34
  • GatewayAttributes.java:11-13
  • GatewayErrorHandler.java:44-98

错误处理

GatewayErrorHandler 是统一错误出口:

  • StickyUnavailableException → 对应 httpStatus,通常是 503,消息中包含节点 ID,但不暴露完整后端地址。
  • ResponseStatusException → 根据状态码映射;5xx 会记录警告日志。
  • 上游连接失败或超时 → 502。
  • TimeoutException → 502。
  • 其他异常 → 500。
  • 如果响应已提交(例如 SSE 中途出错),不再改写状态码,只记录日志并由 Netty 断开连接。

章节来源

  • GatewayErrorHandler.java:13-98
  • StickyUnavailableException.java:5-25

运维排查要点

现象 可能原因 排查方向
登录后仍 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

章节来源

  • StickyRouteFilter.java:75-180
  • LoginResponseFilter.java:104-176
  • GatewayRegistry.java:73-116
  • NodeHealthRegistry.java:27-68
  • CorsConfig.java:10-39

结论

该网关通过“数据库配置 + 运行期快照 + Redis 热缓存 + 用户粘性绑定”的组合,实现了用户会话固定到同一 ai-server 节点的目标。StickyRouteFilter 在 Spring Cloud Gateway 内部完成精确的目标 URL 重写,LoginRequestFilter 与 LoginResponseFilter 配合完成登录前标识与登录后绑定,GatewayRegistry 与 NodeHealthRegistry 提供服务注册与健康视图,GatewayErrorHandler 统一输出结构化错误。

最关键的设计决策是:绑定节点不可用时不自动切换。这牺牲了部分可用性,但保证了拥有本地磁盘状态的 ai-server 节点与用户数据的一致性。运维侧应通过合理配置 default_node_id、健康探针、管理接口和监控告警来平衡稳定性与可维护性。