资讯详情

资讯详情

大模型网关工程化重构:Maven与Spring Boot实战

1. 项目缘起与整体架构思路1.1 从单体到网关为什么要做这次“脱胎换骨”做过几年 Java 后端的人都有一个共同的感受项目刚开始的时候代码怎么写都行一个 Spring Boot 启动类加上几个 Controller 就能跑起来。可一旦业务膨胀、团队扩张、需求从“能用”变成“好用且可维护”原来那套写法就开始处处掣肘。我这次要聊的就是一次真实发生在自己项目里的重构——把一个大模型调用网关从“能跑”的状态硬生生拔高到“工程化”的水准。这个网关的核心职责很明确对外提供统一的 LLM 调用入口对内屏蔽不同模型供应商的差异同时承担鉴权、限流、计费、日志、重试、降级这些横切关注点。听起来是不是很像一个标准的 API Gateway没错本质上它就是一个垂直领域的网关。但问题在于我第一版写得太随意了——Controller 里直接 new 客户端、配置硬编码在代码里、异常处理全靠 try-catch 一把梭、依赖版本满天飞。这种代码自己看着都心虚更别提交给别人维护。所以这次“脱胎换骨”的目标很清晰用 Maven 做规范的依赖管理用 Spring Boot 做工程骨架用 MyBatis 做持久层把整个项目从“脚本式开发”拉到“工程化开发”的轨道上来。为什么选这三件套因为它们构成了 Java 后端最成熟、最稳定、社区资料最丰富的一套组合。你可能会说现在都流行响应式、云原生那一套了但对于一个需要快速落地、团队上手成本低、后期维护有保障的网关项目来说Spring Boot MyBatis 依然是性价比最高的选择。1.2 分层设计网关到底该分几层我见过很多网关项目代码结构乱得一塌糊涂Controller 里塞业务逻辑Service 里写 SQLMapper 里做参数校验。这种“三层不分”的写法短期看是省事长期看就是灾难。这次重构我定了一个原则每一层只做自己该做的事跨层调用必须通过接口。具体分层是这样的接入层Controller只负责参数接收、基础校验、结果封装不碰任何业务逻辑。应用层Service编排业务流程比如“先鉴权、再限流、然后路由到具体模型、最后记录日志”。领域层Domain封装核心业务规则比如计费策略、模型路由规则、重试策略。基础设施层InfrastructureMyBatis Mapper、外部 HTTP 客户端、缓存实现、配置读取。为什么要分这么细因为网关这个场景有个特点横切逻辑特别多。鉴权、限流、日志、计费、重试、降级这些逻辑如果全塞在 Service 里代码会迅速膨胀到无法维护。分层之后每一块逻辑都有明确的归属改限流策略不会影响到计费逻辑换模型供应商不会动到鉴权代码。这里有个经验分层不是目的隔离变化才是。你在设计分层的时候先想清楚“哪些东西未来最可能变”然后把它们单独放一层。1.3 技术选型的取舍逻辑说说为什么是这几个技术而不是别的。Maven 而不是 GradleGradle 确实更灵活、构建更快但 Maven 的优势在于“约定大于配置”和“所有人都看得懂”。网关项目往往需要多人协作Maven 的 pom.xml 结构清晰依赖冲突排查有成熟的工具链比如mvn dependency:tree新人上手几乎没有学习成本。而且热词里那么多人搜“maven 依赖报错”“maven 配置阿里云仓库”说明 Maven 的生态和问题解决方案是最丰富的。Spring Boot 而不是原生 Spring这个没什么好纠结的。Spring Boot 的自动配置、起步依赖、内嵌容器直接把项目搭建成本降了一个数量级。网关需要快速迭代Spring Boot 的 DevTools 热部署和 Actuator 监控端点能省下大量调试时间。MyBatis 而不是 JPA网关的持久层需求其实不复杂主要是记录调用日志、存储计费明细、管理 API Key。这些场景下SQL 的可控性比 ORM 的自动化更重要。MyBatis 允许你精确控制每一条 SQL对于需要做分表、批量插入、复杂查询的日志场景比 JPA 灵活得多。而且 MyBatis 的二级缓存机制在网关这种读多写少的配置类数据场景下能有效减少数据库压力。2. 核心细节解析与实操要点2.1 Maven 依赖管理别让版本冲突毁掉你的项目Maven 用得好是神器用不好就是噩梦。我踩过最深的坑就是依赖冲突——两个不同的库引用了同一个依赖的不同版本运行时抛出NoSuchMethodError排查半天才发现是版本问题。统一版本管理是第一步。在父 pom 的dependencyManagement里把所有核心依赖的版本锁死dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version3.2.0/version typepom/type scopeimport/scope /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency /dependencies /dependencyManagement这样做的好处是子模块引用依赖时不需要写版本号统一由父 pom 控制。升级 Spring Boot 版本时只需要改一个地方。阿里云仓库配置是另一个必做项。默认的 Maven 中央仓库在国内访问速度感人配置阿里云镜像后下载速度能提升十倍以上。在settings.xml的mirrors节点里加上mirror idaliyunmaven/id mirrorOfcentral/mirrorOf name阿里云公共仓库/name urlhttps://maven.aliyun.com/repository/public/url /mirror注意mirrorOf写central表示只镜像中央仓库如果你用了其他仓库比如公司私服不要写成*否则会把所有请求都劫持到阿里云导致私服依赖拉不下来。依赖冲突排查的实操方法执行mvn dependency:tree -Dverbose输出会显示所有依赖的传递关系。如果看到同一个 artifact 出现多个版本用exclusions排除掉不需要的那个。我一般会在父 pom 里对常见的冲突源比如commons-logging、slf4j的多个绑定做统一排除。2.2 Spring Boot 工程骨架从启动类到配置分离Spring Boot 项目的骨架看似简单但细节决定成败。启动类的位置很关键——它必须在所有其他类的父包下否则自动配置扫描不到。我见过有人把启动类放在com.example.gateway.config包下结果所有 Service 都注入失败排查了半天。配置文件分离是工程化的基本要求。我一般会分三个文件application.yml通用配置所有环境共享。application-dev.yml开发环境本地数据库、调试日志。application-prod.yml生产环境连接池调优、敏感信息从环境变量读取。启动时通过--spring.profiles.activeprod指定环境。这样做的好处是生产环境的数据库密码不会出现在代码仓库里而是通过环境变量注入。端口号修改这个看似简单的事其实有讲究。热词里有人搜“spring boot 修改 demo 端口号”说明这是新手常问的问题。在application.yml里写server: port: 8080但如果你在网关项目里用固定端口多实例部署时会冲突。更好的做法是用server.port0让系统随机分配然后通过服务注册中心来发现。当然如果是单机部署固定端口也没问题。MyBatis 配置打印 SQL是调试利器。在开发环境打开mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样每次执行 SQL 都会在控制台打印出来方便你检查参数绑定和结果映射。但生产环境一定要关掉否则日志量会爆炸。2.3 MyBatis 持久层设计日志表与配置表的差异化处理网关的持久层有两类完全不同的数据高频写入的调用日志和低频读取的配置数据。这两类数据的处理策略必须区别对待。调用日志表的特点是写入量大、查询频率低、数据保留期有限。我设计的表结构大致如下CREATE TABLE llm_call_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, request_id VARCHAR(64) NOT NULL, api_key_id BIGINT NOT NULL, model_name VARCHAR(64) NOT NULL, prompt_tokens INT DEFAULT 0, completion_tokens INT DEFAULT 0, latency_ms INT DEFAULT 0, status VARCHAR(16) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_api_key_created (api_key_id, created_at), INDEX idx_request_id (request_id) );写入用 MyBatis 的批量插入每 100 条或每 500 毫秒刷一次避免每条日志都单独走一次数据库连接。这里有个坑MyBatis 的foreach批量插入在数据量大时会生成超长 SQL可能超过数据库的max_allowed_packet限制。我的做法是分批每批 500 条用ExecutorType.BATCH模式执行。配置表的特点是读取频繁、写入极少、数据量小。这类数据最适合用 MyBatis 的二级缓存。在 Mapper XML 里加上cache evictionLRU flushInterval600000 size512 readOnlytrue/flushInterval设为 10 分钟意味着配置变更后最多 10 分钟生效。对于网关的模型路由配置、限流阈值这类数据这个延迟完全可以接受。如果要求实时生效可以在配置更新时手动调用sqlSession.clearCache()。注意MyBatis 二级缓存是 namespace 级别的多个 Mapper 共享同一个 namespace 时会互相影响。我一般让每个 Mapper 独立 namespace避免缓存污染。3. 实操过程与核心环节实现3.1 环境搭建从零到跑通第一个接口假设你现在拿到一台干净的开发机我带你走一遍完整的搭建流程。第一步安装 JDK 和 Maven。JDK 选 17 或 21这两个都是 LTS 版本Spring Boot 3.x 要求最低 JDK 17。Maven 选 3.9.x下载后解压配置MAVEN_HOME和PATH环境变量。验证安装java -version mvn -version如果mvn -version报错“JAVA_HOME not found”说明环境变量没配好。在 macOS 上JAVA_HOME应该指向 JDK 的安装目录比如/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home。第二步生成项目骨架。用 Spring Initializr 或者直接手写 pom。我习惯手写因为能精确控制每个依赖。核心依赖就四个spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-j、lombok。第三步配置数据源。在application-dev.yml里spring: datasource: url: jdbc:mysql://localhost:3306/llm_gateway?useSSLfalseserverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD:123456} hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000连接池用 HikariCPSpring Boot 默认就是它。maximum-pool-size设 20 是经验值网关的数据库操作主要是日志写入20 个连接足够支撑每秒几千次调用。设太大反而会增加数据库负担。第四步写第一个 Mapper。创建一个ApiKeyMapper接口和对应的 XMLMapper public interface ApiKeyMapper { ApiKey selectByKeyHash(Param(keyHash) String keyHash); }select idselectByKeyHash resultTypecom.example.gateway.domain.ApiKey SELECT id, key_hash, user_id, status, quota_limit, quota_used FROM api_key WHERE key_hash #{keyHash} AND status ACTIVE /select第五步启动验证。运行mvn spring-boot:run看到 “Started GatewayApplication” 就说明环境通了。然后用 curl 测一下curl -X POST http://localhost:8080/api/v1/chat \ -H Authorization: Bearer sk-xxx \ -H Content-Type: application/json \ -d {model:gpt-3.5-turbo,messages:[{role:user,content:hello}]}如果返回 401说明鉴权拦截器生效了如果返回 200 但内容是空的说明模型调用链路还没通。一步步排查先确保每一层都能独立工作。3.2 鉴权与限流网关的第一道防线鉴权逻辑我放在一个HandlerInterceptor里在preHandle阶段完成。核心流程是从 Header 取出 API Key计算哈希查缓存缓存没有就查数据库验证状态和配额。Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { response.setStatus(401); return false; } String apiKey authHeader.substring(7); String keyHash DigestUtils.sha256Hex(apiKey); ApiKey key apiKeyCache.get(keyHash); if (key null) { key apiKeyMapper.selectByKeyHash(keyHash); if (key ! null) { apiKeyCache.put(keyHash, key); } } if (key null || !ACTIVE.equals(key.getStatus())) { response.setStatus(401); return false; } if (key.getQuotaUsed() key.getQuotaLimit()) { response.setStatus(429); return false; } request.setAttribute(apiKey, key); return true; }限流我用的是令牌桶算法基于 Redis 实现。每个 API Key 对应一个桶桶的容量和填充速率从配置表读取。为什么不用 Guava 的 RateLimiter因为它是单机的多实例部署时每个实例独立限流总流量会超标。Redis 方案虽然多了一次网络调用但能保证全局一致性。public boolean tryAcquire(Long apiKeyId, int permits) { String key rate_limit: apiKeyId; long now System.currentTimeMillis(); redisTemplate.opsForZSet().removeRangeByScore(key, 0, now - windowMs); Long count redisTemplate.opsForZSet().zCard(key); if (count ! null count limit) { return false; } redisTemplate.opsForZSet().add(key, String.valueOf(now), now); redisTemplate.expire(key, windowMs, TimeUnit.MILLISECONDS); return true; }这里有个坑Redis 的 ZSet 在并发高时会有 race condition。两个请求同时读到 countlimit-1然后都执行 add结果超限。解决方案是用 Lua 脚本把“读-判断-写”三步原子化。我实测下来用 Lua 脚本后限流精度能控制在 1% 以内。3.3 模型路由与重试让调用更可靠网关的核心价值之一就是屏蔽模型差异。不同供应商的 API 格式、认证方式、错误码都不一样。我在应用层定义了一个统一的ModelClient接口public interface ModelClient { ChatResponse chat(ChatRequest request); String getProvider(); }每个供应商实现这个接口比如OpenAiClient、AzureClient、AnthropicClient。路由逻辑根据请求里的model字段选择对应的 Client。如果请求没指定模型就用默认模型。重试策略是网关可靠性的关键。我的做法是只对可重试的错误超时、5xx、限流进行重试重试次数默认 2 次退避策略用指数退避加随机抖动。public ChatResponse chatWithRetry(ChatRequest request, int maxRetries) { int attempt 0; while (true) { try { return modelClient.chat(request); } catch (RetryableException e) { attempt; if (attempt maxRetries) { throw e; } long backoff (long) (Math.pow(2, attempt) * 100 Math.random() * 100); Thread.sleep(backoff); } } }为什么加随机抖动因为如果多个请求同时失败不加抖动的话它们会在同一时刻重试形成“重试风暴”把下游服务打垮。加 100 毫秒以内的随机抖动能把重试请求分散开。降级策略是最后一道防线。当所有重试都失败时返回一个预定义的降级响应而不是直接抛异常给用户。降级响应可以是一个缓存的结果或者一个友好的错误提示。4. 常见问题与排查技巧实录4.1 依赖与构建类问题速查问题现象可能原因排查方法解决方案NoSuchMethodError依赖版本冲突mvn dependency:tree用exclusions排除旧版本ClassNotFoundException依赖未引入或 scope 错误检查 pom 的 scope改为compile或runtime下载依赖极慢未配置国内镜像检查settings.xml配置阿里云镜像mvn clean install失败测试用例报错看 surefire 报告跳过测试-DskipTests或修复测试多模块依赖找不到子模块未 install检查本地仓库先mvn install父模块依赖冲突的深度排查有时候dependency:tree显示只有一个版本但运行时还是报错。这种情况多半是类加载顺序问题。两个不同的 jar 包里包含了同名的类JVM 加载了错误的那个。用mvn dependency:tree -Dverbose加上-DincludesgroupId:artifactId可以精确定位。我踩过的一个坑项目里同时引入了log4j和logback结果日志输出到了两个文件里。排查了半天才发现是spring-boot-starter-logging和某个第三方库的传递依赖冲突。解决方案是用exclusions排除掉log4j统一用logback。4.2 数据库与 MyBatis 类问题MyBatis 二级缓存不生效是高频问题。检查三点一是在 Mapper XML 里加了cache/标签二是mybatis.configuration.cache-enabled为true默认就是三是查询语句所在的 namespace 和缓存配置在同一个 XML 里。如果这三点都满足还不生效检查是不是用了sqlSession.clearCache()或者执行了更新操作——更新会清空整个 namespace 的缓存。批量插入性能差很多人用foreach拼接 SQL数据量大时 SQL 长度爆炸。更好的做法是用ExecutorType.BATCHSqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH); try { for (LogEntry entry : entries) { logMapper.insert(entry); } session.commit(); } finally { session.close(); }这种方式下MyBatis 会把多条 insert 合并成一个 JDBC batch性能提升非常明显。我实测过1000 条日志的插入时间从 2 秒降到了 200 毫秒。连接池耗尽网关在高并发时容易出现Connection is not available错误。排查方法是看 HikariCP 的监控指标如果activeConnections持续等于maximumPoolSize说明连接不够用。解决方案有两个一是增大连接池二是检查是否有慢查询占着连接不放。我一般会加一个慢查询日志超过 1 秒的 SQL 都记录下来。4.3 网关特有的坑与应对超时设置不合理网关调用下游模型服务如果超时设得太短正常的长文本生成会被中断设得太长慢请求会拖垮整个网关。我的经验值是连接超时 5 秒读取超时根据模型类型区分——对话模型 60 秒长文本生成 300 秒。而且要在网关层和 HTTP 客户端层都设置超时任何一层漏了都可能导致线程池被占满。请求体过大有些用户会把整个文档塞进 prompt 里请求体可能达到几十 MB。Spring Boot 默认的请求体大小限制是 2MB超过会报MaxUploadSizeExceededException。在application.yml里调整spring: servlet: multipart: max-request-size: 50MB max-file-size: 50MB但更重要的是在网关层做校验超过限制的请求直接拒绝避免浪费下游资源。日志脱敏调用日志里可能包含用户的 prompt 内容这些内容可能涉及隐私。我的做法是日志里只记录 prompt 的哈希值和 token 数量不记录原文。如果确实需要记录原文用于调试必须加密存储并且设置自动过期时间。最后分享一个排查技巧网关出问题时第一件事是看request_id。我在每个请求进入网关时生成一个 UUID贯穿整个调用链路包括日志、数据库记录、下游请求的 Header。排查时用grep request_id就能把一次调用的所有痕迹串起来效率比瞎猜高十倍。这个项目后续还可以这样扩展把限流策略从固定阈值改成基于历史流量的动态调整用滑动窗口替代令牌桶来获得更平滑的限流效果把模型路由从静态配置改成基于实时延迟和成功率的智能路由把日志存储从 MySQL 迁移到 ClickHouse 或 Elasticsearch支撑更大规模的查询分析。每一步扩展都建立在这套工程骨架之上这也是为什么我坚持先把基础打牢——地基不稳楼越高越危险。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →