多节点扩展与负载均衡.md 32 KB

多节点扩展与负载均衡

本文引用的文件

  • StickyRouteFilter.java
  • LoginRequestFilter.java
  • LoginResponseFilter.java
  • UserRouteService.java
  • UserRouteRepository.java
  • TokenResolver.java
  • NodeSelector.java
  • GatewayAttributes.java
  • GatewayRegistry.java
  • NodeHealthRegistry.java
  • HealthChecker.java
  • ServiceInfo.java
  • NodeInfo.java
  • StickyUnavailableException.java
  • gateway.sql

目录

  1. 引言
  2. 项目结构
  3. 核心组件
  4. 架构总览
  5. 详细组件分析
  6. 依赖关系分析
  7. 性能与水平扩展
  8. 监控与排障
  9. 结论

引言

本方案为 ai-server 提供多节点扩展与粘性负载均衡。核心约束是:同一用户的请求必须始终路由到同一个 ai-server 节点,避免跨节点访问本地磁盘、附件、DuckDB 案件数据以及会话状态不一致。网关层不实现传统意义上的“自动故障转移”,而是通过“用户 → 节点”绑定 + 健康检查 + 登录绑定机制,在绑定节点不可用时返回 503,由运维或上层重试控制切换。

项目结构

代码位于两个主要子模块中:

  • ai-gateway:Spring Cloud Gateway 实现的粘性网关,负责服务发现、节点健康检查、用户粘性路由、登录前后处理、Redis 缓存等。
  • 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
    

图示来源

  • StickyRouteFilter.java:41-179
  • LoginRequestFilter.java:38-119
  • LoginResponseFilter.java:41-175
  • UserRouteService.java:25-116
  • UserRouteRepository.java:17-200
  • TokenResolver.java:25-99
  • NodeSelector.java:18-44
  • GatewayRegistry.java:17-115
  • NodeHealthRegistry.java:10-67
  • HealthChecker.java:17-61
  • gateway.sql:1-107

章节来源

  • StickyRouteFilter.java:1-180
  • LoginRequestFilter.java:1-120
  • LoginResponseFilter.java:1-176
  • UserRouteService.java:1-117
  • UserRouteRepository.java:1-227
  • gateway.sql:1-107

核心组件

  • StickyRouteFilter:粘性路由核心过滤器,从 x-token 解析 userId,查找绑定节点,校验节点可用,重写目标 URL,并附加 X-Gateway-Node 响应头。
  • LoginRequestFilter:登录前预处理,解析 username,标记登录请求,允许下游 StickyRouteFilter 按 username 查找已有绑定。
  • LoginResponseFilter:登录成功后把用户绑定到 StickyRouteFilter 选出的节点,并把 token → userId 写入 Redis 缓存。
  • UserRouteService:用户路由的 Redis 两级缓存与 PostgreSQL 事实源读写。
  • UserRouteRepository:JDBC 访问 gateway_user_route 及 sys_user,实现幂等绑定和并发安全。
  • TokenResolver:x-token → userId 解析,优先走网关 token 缓存,未命中时走 sa-token。
  • NodeSelector:匿名请求和新用户选择健康节点的轮询器。
  • GatewayRegistry:服务与节点配置内存快照,从 PostgreSQL 加载,支持 YAML 播种和多实例对账。
  • NodeHealthRegistry:每个网关实例维护的节点健康状态。
  • HealthChecker:定时调用 ai-server 健康端点,更新 NodeHealthRegistry。
  • StickyUnavailableException:粘性路由不可用时的统一异常,HTTP 状态码固定为 503。

章节来源

  • StickyRouteFilter.java:41-179
  • LoginRequestFilter.java:38-119
  • LoginResponseFilter.java:41-175
  • UserRouteService.java:25-116
  • UserRouteRepository.java:17-200
  • TokenResolver.java:25-99
  • NodeSelector.java:18-44
  • GatewayRegistry.java:17-115
  • NodeHealthRegistry.java:10-67
  • HealthChecker.java:17-61
  • StickyUnavailableException.java:7-24

架构总览

粘性路由不是简单的负载均衡,而是“用户亲和 + 有限可用性”的组合:

  • 匿名用户和无绑定用户按健康节点轮询。
  • 已登录用户通过 userId 或 username 绑定到具体节点。
  • 绑定节点 DOWN 时返回 503,不自动切换到其他节点。
  • 服务与节点配置存储在 PostgreSQL,网关启动时可从 YAML 播种。
  • 健康检查只判断进程存活,不深入检查 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"
    

图示来源

  • LoginRequestFilter.java:38-119
  • StickyRouteFilter.java:60-179
  • LoginResponseFilter.java:60-175
  • UserRouteService.java:73-116
  • TokenResolver.java:43-99

详细组件分析

粘性路由 StickyRouteFilter

StickyRouteFilter 是粘性路由的核心。其职责包括:

  1. 根据路由元数据获取 serviceId。
  2. 判断是否为登录请求。
  3. 登录请求:先查 username 是否已有绑定;若无,则从健康节点中选择一个候选节点,但不写库。
  4. 普通请求:从 x-token 解析 userId;若 userId 有效但无绑定,尝试绑定到 defaultNodeId;若仍无绑定,返回 503。
  5. token 解析失败时,按匿名请求处理,让后端返回 401。
  6. 最终校验选定节点是否存在、启用且健康;通过后重写 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(["继续下游过滤链"])
    

图示来源

  • StickyRouteFilter.java:60-179
  • StickyUnavailableException.java:16-24

关键设计点:

  • order 为 10001,位于 Spring Cloud Gateway 内置 RouteToRequestUrlFilter 之后,用于重写实际目标 URL。
  • 目标 URL 使用 baseUrl + requestUri.getRawPath() + requestUri.getRawQuery() 拼接,避免二次编码。
  • 所有“不可用”场景统一抛出 StickyUnavailableException.serviceUnavailable,HTTP 状态码为 503。

章节来源

  • StickyRouteFilter.java:41-179
  • StickyUnavailableException.java:7-24

登录请求处理 LoginRequestFilter

LoginRequestFilter 只在配置的 loginFullPath 上生效,流程如下:

  1. 通过 GatewayRegistry 找到对应 ServiceInfo。
  2. 合并读取请求体,限制最大 64KB。
  3. 解析 JSON body 中的 username。
  4. 设置 GatewayAttributes.LOGIN_REQUEST 和 LOGIN_USERNAME。
  5. 将 Accept-Encoding 改为 identity,避免后端 gzip 导致响应无法被 LoginResponseFilter 解析。
  6. 重建请求体,确保下游仍能消费原始 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["进入下游过滤器"]
    

图示来源

  • LoginRequestFilter.java:38-119

章节来源

  • LoginRequestFilter.java:1-120

登录响应绑定 LoginResponseFilter

LoginResponseFilter 在响应写出阶段拦截登录响应:

  1. 仅对 pathPrefix + loginPath 的 POST 响应处理。
  2. 收集响应体,上限 256KB。
  3. 解析 Result,要求 code=200,data.userId 非空,data.username 非空。
  4. 从 exchange 属性中取出 StickyRouteFilter 设置的 SERVICE_ID 和 LOGIN_CHOSEN_NODE_ID。
  5. 调用 userRouteService.bindIfAbsent 做幂等绑定。
  6. 调用 tokenResolver.cacheToken 写入 token → userId 缓存。
  7. 无论绑定成功与否,都不修改原始响应体。

    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: "透传原始响应"
    
  8. 图示来源

    • LoginResponseFilter.java:60-175
    • UserRouteService.java:73-116
    • TokenResolver.java:43-99

    章节来源

    • LoginResponseFilter.java:1-176

    用户路由服务与持久化

    UserRouteService 提供两类能力:

    • 读路径:先从 Redis 缓存读取,未命中再查 PostgreSQL,然后回写缓存。
    • 写路径:通过 UserRouteRepository 执行幂等绑定,成功后同时写 userId 和 username 对应的缓存键。

    UserRouteRepository 使用 JdbcTemplate 直接操作 PostgreSQL:

    • findNodeIdByUserId 和 findNodeIdByUsername 查询绑定。
    • bindIfAbsent 先查已有绑定,再 INSERT ON CONFLICT DO NOTHING,最后读回赢家,保证并发下唯一性。
    • importExisting 支持存量用户批量导入到某个节点。

    数据库模型由 gateway.sql 定义:

    • gateway_service:服务定义,包含 path_prefix、login_path、health_path、default_node_id。
    • gateway_node:节点定义,base_url 不含 context-path。
    • 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
      }
      

    图示来源

    • gateway.sql:1-107

    章节来源

    • UserRouteService.java:25-116
    • UserRouteRepository.java:17-200
    • gateway.sql:1-107

    Token 解析与缓存

    TokenResolver 负责将 x-token 转为 userId:

    1. 如果 token 为空,直接返回空。
    2. 从 Redis 键 gateway:token:{serviceId}:{token} 读取 userId。
    3. 缓存未命中时调用 StpUtil.getLoginIdByToken 从 sa-token 共享 Redis 解析。
    4. 解析成功后写回缓存,TTL 24 小时。
    5. 解析失败返回空 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"]
      

    图示来源

    • TokenResolver.java:43-99

    章节来源

    • TokenResolver.java:1-100

    节点选择与轮询

    NodeSelector 为匿名请求和新用户选择节点:

    • 只考虑 enabled=true 且 NodeHealthRegistry.isUp=true 的节点。
    • 使用基于 serviceId 的 AtomicInteger 轮询。
    • 没有可用节点时返回 null,由调用方返回 503。

    注意:NodeSelector 不影响已绑定用户;已绑定用户即使 DOWN 也不会被换掉。

    章节来源

    • NodeSelector.java:18-44

    服务注册中心与配置同步

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

    • 启动时若 gateway_service 为空,则从 YAML 播种。
    • reload() 从 PostgreSQL 重新加载,变化时发布 RefreshRoutesEvent。
    • 每 10 秒轮询一次,实现多网关实例配置收敛。
    • 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 : "包含"
      

    图示来源

    • GatewayRegistry.java:17-115
    • ServiceInfo.java:7-31
    • NodeInfo.java:5-12

    章节来源

    • GatewayRegistry.java:17-115
    • ServiceInfo.java:7-31
    • NodeInfo.java:5-12

    节点健康检查与健康状态

    HealthChecker 定时对所有启用节点发起 GET {baseUrl}{healthPath}:

    • 成功时记录 UP,连续失败达到阈值记录 DOWN。
    • 1 次成功立即恢复 UP。
    • UNKNOWN 初始状态视为可用,避免刚启动时全部匿名请求 503。

    NodeHealthRegistry 是每个网关实例的本地健康状态表,不跨实例同步。

    章节来源

    • HealthChecker.java:17-61
    • NodeHealthRegistry.java:10-67

    依赖关系分析

    粘性路由相关组件的依赖关系如下:

    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
    

    图示来源

    • StickyRouteFilter.java:41-179
    • LoginResponseFilter.java:41-175
    • UserRouteService.java:25-116
    • UserRouteRepository.java:17-200
    • TokenResolver.java:25-99
    • HealthChecker.java:17-61
    • GatewayRegistry.java:17-115

    章节来源

    • StickyRouteFilter.java:41-179
    • UserRouteService.java:25-116
    • TokenResolver.java:25-99
    • GatewayRegistry.java:17-115

    性能与水平扩展

    多节点部署拓扑

    推荐拓扑:

    • 多个 ai-server 实例,每个实例持有本地磁盘、附件、DuckDB 案件数据、Agent 会话。
    • 多个 ai-server 实例共享 PostgreSQL/pgvector、Redis、Elasticsearch、Neo4j 等外部存储。
    • 单个或集群式 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
      

    该图是概念部署图,不映射单一源码文件,因此不提供图示来源。

    水平扩展建议

    • 节点扩容策略
      • 新增 ai-server 节点后,先在 PostgreSQL 中登记 gateway_node,并在 gateway_service 中关联。
      • 存量用户可通过管理接口重绑,或使用 importExisting 将未绑定用户迁移到新节点。
      • 新用户首次登录由 NodeSelector 轮询健康节点,后续由 sticky 绑定保持亲和。
    • 登录顶号与会话释放
      • 当账号被顶下线或踢下线时,Sa-Token 会失效,TokenResolver 解析不到 userId,StickyRouteFilter 退化为匿名轮询,后端返回 401 引导重新登录。
      • 被顶下线用户下次登录可能落到新节点,这是粘性路由的预期行为;如需强制原节点,需通过管理接口重新绑定。
    • DuckDB 最大案件数与内存比例
      • 由于案件数据在节点本地,扩容时必须避免把一个逻辑用户的多个案件分散到不同节点。
      • 建议按用户维度分配 ai-server 节点,并结合 DuckDB 最大案件数和 JVM 堆内存调整,防止单节点内存打满。
    • 向量索引重建成本
      • pgvector 向量索引重建通常较重,建议在低峰期按节点分批执行,避免同时影响多个 ai-server。
    • Elasticsearch 与 Neo4j 共享配置
      • ES/Neo4j 是共享后端,不应成为粘性路由的限制因素;但要注意连接池、线程池、索引刷新频率对多节点并发访问的影响。
    • Redis 热点键
      • 路由缓存 key 粒度为 serviceId + userId 或 serviceId + username,token 缓存 key 粒度为 serviceId + token。
      • 大用户量下应避免把所有流量集中到一个 Redis 节点;建议使用分片或集群模式,并注意连接池容量。

    [本节为通用扩展建议,不直接分析具体源码行]

    监控与排障

    节点健康状态查询

    • 关注 NodeHealthRegistry 的状态变化日志,特别是 DOWN 状态输出。
    • 确认 HealthChecker 是否正常调度,健康端点是否正确返回 2xx。
    • 若节点持续 DOWN,检查 ai-server 进程、健康端点、网络连通性。

    章节来源

    • NodeHealthRegistry.java:10-67
    • HealthChecker.java:17-61

    粘性路由失效

    常见表现:同一用户请求落到不同节点。 排查要点:

    1. 检查 StickyRouteFilter 是否拿到 route 和 serviceId。
    2. 检查 x-token 是否能被 TokenResolver 正确解析为 userId。
    3. 检查 Redis 中 gateway:token:{serviceId}:{token} 是否存在。
    4. 检查 PostgreSQL 中 gateway_user_route 是否有 serviceId + userId 或 serviceId + username 的记录。
    5. 检查是否误用了匿名轮询分支。

    章节来源

    • StickyRouteFilter.java:60-179
    • TokenResolver.java:43-99
    • UserRouteRepository.java:37-70
    • gateway.sql:60-107

    登录绑定错乱

    常见表现:登录后下一次请求路由到不同节点。 排查要点:

    1. 检查 LoginRequestFilter 是否正确解析 username。
    2. 检查 LoginResponseFilter 是否正确识别 loginFullPath。
    3. 检查 LoginResponseFilter 是否成功解析 code=200 且 data.userId、data.username 非空。
    4. 检查 UserRouteService.bindIfAbsent 是否落库成功。
    5. 检查是否有多台网关实例竞争绑定,但唯一索引应保证一致性。

    章节来源

    • LoginRequestFilter.java:38-119
    • LoginResponseFilter.java:60-175
    • UserRouteRepository.java:84-120

    Redis 热点键

    风险点:

    • gateway:token:{serviceId}:{token} 在高并发下可能成为热点。
    • gateway:route:user:{serviceId}:{userId} 和 gateway:route:name:{serviceId}:{username} 也可能热点。

    定位方法:

    1. 观察 Redis 连接池使用率和慢查询。
    2. 统计高频 token 和用户 ID 的请求分布。
    3. 如 Redis 成为瓶颈,考虑增加分片、提升单机性能,或评估是否将 token 校验更多下沉到 ai-server。

    章节来源

    • TokenResolver.java:43-99
    • UserRouteService.java:41-116

    数据库连接池耗尽

    风险点:

    • UserRouteRepository 使用 JdbcTemplate,阻塞 IO 放在 boundedElastic 执行。
    • 高并发下 PostgreSQL 连接过多可能导致连接池耗尽。

    定位方法:

    1. 监控 PostgreSQL 活跃连接数、慢查询、锁等待。
    2. 检查 JDBC 连接池大小、超时配置。
    3. 减少频繁的用户路由查询,避免在热路径上重复查库。
    4. 对存量用户导入使用 batch 方式,避免大量小事务。

    章节来源

    • UserRouteRepository.java:17-200
    • UserRouteService.java:41-72

    绑定节点 DOWN 的处理

    当前实现明确不自动切换:

    • StickyRouteFilter 校验节点健康状态,DOWN 时抛出 503。
    • StickyUnavailableException.serviceUnavailable 统一返回 HTTP 503。
    • 这不是 bug,而是为了避免跨节点访问本地磁盘和会话不一致。

    修复思路:

    • 运维层面:尽快恢复节点或手动迁移用户绑定。
    • 应用层面:若确实需要自动故障转移,应在业务层引入跨节点状态同步,但这会显著改变系统语义。

    章节来源

    • StickyRouteFilter.java:120-179
    • StickyUnavailableException.java:7-24

    结论

    本方案以“用户 → 节点”绑定为核心,结合登录前后处理器、Redis 缓存、PostgreSQL 持久化和健康检查,实现 ai-server 的多节点粘性扩展。其关键约束是:绑定节点不可用时返回 503,不自动切换,以保证本地磁盘和会话一致性。生产扩展时应重点关注 Redis 热点、数据库连接池、DuckDB 内存占用、向量索引重建成本,以及 ES/Neo4j 共享资源的并发压力。治理粘性路由问题应从 token 解析、用户绑定、服务配置、节点健康四个维度逐步定位。