做 AI 应用的朋友应该都有体会同一张图老手能生成得又快又稳新手可能连接口都调不明白或者好不容易调通了一上线就被刷爆了配额。这段时间我正好把一个 AI 生图功能从零接入到一个 Spring Boot 3 的现有后端里从最初的能出图到后来的稳、快、不烧钱踩了不少坑也沉淀了一套比较完整的做法。这篇就把它拆开揉碎手把手复现一遍重点是两件事一是怎么设计一条工业级的 AI 生图管道也就是从用户请求进来到最后拿到图片 URL 的完整链路二是怎么挡住恶意刷接口的流量。先说点背景。gpt-image-2.5 这类图像生成接口核心能力其实不复杂你传一段 Prompt提示词和一些生成参数它给你返回一张或多张图。但放在生产环境里事情远没有这么简单。直接调用有什么问题第一生图接口普遍偏慢同步等待会让 HTTP 请求长时间挂起用户体验差不说应用服务器的线程也被白白占着第二生成结果需要二次获取逻辑上天然是异步的第三生图是有成本的如果接口直接被公开恶意用户拿你的 Key 疯狂刷图账单会非常难看。所以接入的核心不是调通 API而是把 API 包成一个可控、可靠、可计费的业务能力。这篇分享面向的是有一定 Java 基础、想在自己的后端项目里集成 AI 生图能力的开发者。零基础的同学也能跟上但至少要了解 Spring Boot 的基本概念、HTTP 调用和 Redis 的基本用法。我会把设计思路、核心代码、参数选型、常见坑位全部摊开讲你可以直接照着抄也可以根据项目情况做裁剪。1. 整体架构设计与选型思路1.1 为什么不能直接调接口管道层到底解决什么问题很多人在接入 AI 生图时第一个想法是Controller 里直接调用 gpt-image-2.5 的 HTTP 接口返回图片 URL 完事。Demo 可以生产不行原因有三个。第一超时与资源占用。生图接口慢的时候能跑到几十秒甚至分钟级。在 Spring Boot 默认的 Tomcat 线程模型下一个请求占一个线程慢请求会把线程池打满其他接口跟着遭殃。虽然也能调大 ThreadPool但治标不治本服务器资源经不起这么消耗。第二状态不可控。调用第三方接口你没法假设它永远成功。网络抖动、服务端过载、限流各种异常都可能发生。如果同步调用失败了用户看到的只有一个 500让他重试重试后又可能重复扣费没法收敛。第三成本不可控。生图接口是按张计费的而且费用不低。一旦你的接口被脚本刷轻则账户欠费重则服务被供应商熔断拉黑。这不是危言耸听开放平台被薅羊毛的案例太多了。所以我们要做的管道本质上是加一个中间层。用户请求进来后先经过校验、限流、配额扣减然后丢到一个异步队列里由后台任务去消费并调用上游 API完成后把结果存起来用户通过轮询或回调拿到最终图片。这中间所有不可控的因素都被隔离在业务层之外最终暴露给用户的是一套简洁可控的接口。1.2 技术选型为什么是 Spring Boot 3 Redis 数据库任务表先看整体组件选型每个都有它的理由。Spring Boot 3 是基础这个没什么好说的项目本身就是基于它。需要注意 Spring Boot 3 基于 Jakarta EE 9很多老代码里的 javax 包要换成 jakarta这个后面代码里会体现。异步处理我选的是数据库任务表 线程池方案没有直接上 MQ。原因是这个场景的并发量通常没那么大生图是重操作但提交频率可控用 MQ 反而增加运维复杂度。任务表的好处是天然支持持久化、状态查询和失败重试看一眼表就能知道当前系统有多少任务在跑、卡在哪一步。如果往后流量涨了可以平滑迁移到 MQ代码层面只需要改提交任务和消费任务的实现。缓存和限流用 Redis。限流需要原子计数和过期时间Redis 的 INCR EXPIRE 或者 Lua 脚本是最合适的。配额扣减也是同样道理必须是原子操作不能两个请求同时扣到一个额度上。HTTP 客户端我用的 Spring Boot 3 自带的 RestClient也可以换成 WebClient 或 OkHttp选型逻辑我后面会讲。RestClient 是 Spring 6.1 引入的同步 HTTP 客户端API 风格集成了 RestTemplate 的简单和 WebClient 的链式流畅足够用。存储结构上任务表是核心。它记录了任务的唯一 ID、用户 ID、状态、请求参数、结果信息、失败原因、重试次数等。状态机是这个表的灵魂PENDING排队中、PROCESSING生成中、SUCCESS成功、FAILED失败。后续可以扩展出 CANCELLED取消等状态但核心四个够了。2. 项目搭建与基础配置2.1 依赖引入与版本选择我的项目是基于 Spring Boot 3.2.x 构建的Java 版本用的 17。如果你是老项目需要注意 Spring Boot 3 对 Java 版本的最低要求是 17版本太老的话先升级。pom.xml 里的核心依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency /dependenciesMyBatis 用的是 3.0.3因为这是适配 Spring Boot 3 的版本线。你如果喜欢 JPA 也不是不行但生图任务表这种偏轻量、SQL 比较直观的场景MyBatis 更容易写出可控的 SQL特别是批量更新状态、分页查询这种操作。Redis 这边配置好连接信息就可以了。需要注意的是如果你的 Redis 是集群模式Lua 脚本要保证 key 都在同一个 slot 里后面写限量脚本时会再提。2.2 HTTP 客户端配置超时、连接池与重试调用 gpt-image-2.5 这类接口HTTP 客户端的配置直接决定了稳定性和性能。我踩过的坑是最开始用默认配置结果一个慢请求拖死了整个连接池所有后续请求都在等连接。用 RestClient 需要先构建一个 HttpClient。我建议用 JdkClientHttpRequestFactoryJDK 自带的 HTTP Client或者 Apache HttpClient。这里用 Apache HttpClient因为连接池、超时、重试配置更灵活。Configuration public class HttpClientConfig { Bean public RestClient restClient() { PoolingHttpClientConnectionManager connectionManager PoolingHttpClientConnectionManagerBuilder.create() .setMaxTotal(100) .setDefaultMaxPerRoute(50) .build(); RequestConfig requestConfig RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(60000) .setConnectionRequestTimeout(5000) .build(); CloseableHttpClient httpClient HttpClients.custom() .setConnectionManager(connectionManager) .setDefaultRequestConfig(requestConfig) .setRetryHandler(new DefaultHttpRequestRetryHandler(2, true)) .build(); ClientHttpRequestFactory factory new HttpComponentsClientHttpRequestFactory(httpClient); return RestClient.builder() .requestFactory(factory) .baseUrl(https://api.example.com) // 替换成 gpt-image-2.5 的实际地址 .build(); } }这里的参数是我调整过的。连接超时设成 5 秒够建连了socket 超时设成 60 秒给生图接口留足时间毕竟它是真慢连接池 maxTotal 100单路由 50这是基于生图并发不会太高的预估如果你要支持更多并发按比例上调就行。注意DefaultHttpRequestRetryHandler的重试默认会重试所有异常但只对幂等请求安全。生图请求不是幂等的所以重试策略要谨慎我建议重试次数不要超过 2 次而且最好在业务层做带状态控制的补偿重试而不是在 HTTP 层盲目重投同一个请求。2.3 数据库表结构设计任务表是整条管道的心脏任务表设计得够不够稳直接决定了管道的上限。我先给出表结构然后再说为什么每个字段都是必要的。CREATE TABLE image_task ( id bigint NOT NULL AUTO_INCREMENT, task_id varchar(64) NOT NULL COMMENT 业务任务唯一ID, user_id varchar(64) NOT NULL COMMENT 用户ID, status tinyint NOT NULL DEFAULT 0 COMMENT 0-排队中 1-生成中 2-成功 3-失败, prompt text NOT NULL COMMENT 提示词, size varchar(16) DEFAULT NULL COMMENT 图片尺寸, quality varchar(16) DEFAULT NULL COMMENT 质量参数, n int NOT NULL DEFAULT 1 COMMENT 生成数量, image_urls json DEFAULT NULL COMMENT 结果图片URL列表, error_msg varchar(512) DEFAULT NULL COMMENT 失败原因, retry_count int NOT NULL DEFAULT 0 COMMENT 已重试次数, cost_credits int DEFAULT 0 COMMENT 消耗的配额点数, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_task_id (task_id), KEY idx_user_status (user_id, status), KEY idx_status_created (status, created_at) ) COMMENTAI生图任务表;几个字段的意义要说一下。task_id 是业务侧的唯一 ID由我们自己生成不要让数据库自增主键直接暴露给用户。原因后面讲防刷时再说。status 用 tinyint 是方便索引和比较也不容易被字符串拼写坑到。0 到 3 四个状态含义清晰。prompt、size、quality、n 这些参数必须存下来一方面业务上需要展示历史记录另一方面失败重试时要从库里重新构造上游请求不能依赖用户再传一次。image_urls 用 JSON 类型因为一次可能生成多张图存一个字段比搞关联表简单得多查询也方便。retry_count 必须记这是控制重试次数、防止死循环的抓手。cost_credits 是配额审计用的后面会详细说。3. gpt-image-2.5 接口对接与核心调用实现3.1 请求参数映射与 DTO 设计对接任何一个外部 API第一步都是把外部接口的请求和响应映射成我们自己的内部模型。这里我先根据常见的图像生成接口参数约定设计 DTO同时保留向 gpt-image-2.5 实际文档靠拢的扩展能力。创建任务的请求 DTOpublic record ImageGenerationRequest( NotBlank(message 提示词不能为空) Size(max 1000, message 提示词长度不能超过1000字符) String prompt, Pattern(regexp 256x256|512x512|1024x1024|1536x1024|1024x1536, message 不支持的图片尺寸) String size, Pattern(regexp standard|hd, message 不支持的画质参数) String quality, Min(value 1, message 生成数量最少为1) Max(value 4, message 生成数量最多为4) Integer n ) {}我为什么会这么设计参数因为这些参数决定了上游请求的价格。n 设上限是 4一个用户一次最多生成 4 张防止他拿一个任务生成 100 张图刷爆你的成本。实际对接时你还需要对照 gpt-image-2.5 的接口文档做字段校准比如有些接口支持 style 参数vivid、natural有些支持 response_formaturl、b64_json这些字段必须以你实际拿到的文档为准我的 DTO 只是骨架。对于第三方 AI 接口的响应我处理成统一的外部响应结构核心关注点有两个请求是否成功、结果的获取方式是直接返回 URL 还是返回一个异步任务 ID。这两种模式在对接时差别很大后面会讲到。3.2 上游接口调用与两种响应模式的处理gpt-image-2.5 这类接口我用的是同步调用模式即发起请求后等待接口返回生成结果。调用流程通常是构建 JSON body设置 Authorization 请求头POST 到 /v1/images/generations 之类的路径然后解析结果里的图片 URL 或 base64 数据。按这个模式写代码Service public class ImageGenerationClient { private final RestClient restClient; public ImageCallResult generate(ImageTask task) { MapString, Object body new HashMap(); body.put(prompt, task.getPrompt()); body.put(size, task.getSize() null ? 1024x1024 : task.getSize()); body.put(quality, task.getQuality() null ? standard : task.getQuality()); body.put(n, task.getN() null ? 1 : task.getN()); try { MapString, Object resp restClient.post() .uri(/v1/images/generations) .header(Authorization, Bearer apiKey) .contentType(MediaType.APPLICATION_JSON) .body(body) .retrieve() .body(new ParameterizedTypeReferenceMapString, Object() {}); ListString urls extractImageUrls(resp); return ImageCallResult.success(urls); } catch (RestClientResponseException e) { return ImageCallResult.failed(上游接口异常: e.getStatusCode()); } catch (Exception e) { return ImageCallResult.failed(调用异常: e.getMessage()); } } }这里我把上游错误全部捕获并转换成统一结果而不是直接抛出异常原因在于调用失败不一定是系统故障可能只是上游限流或临时过载任务应该进入可重试状态而不是直接把异常抛给上层导致任务标记失败。这就在代码层面给重试留了后门。这里有第二种情况需要单独说明如果 gpt-image-2.5 实际采用的是异步任务模式——也就是提交后返回一个任务 ID需要后台轮询获取结果——那处理逻辑就要调整。常见的处理套路是加一个提交生成任务的接口和查询任务结果的接口提交成功的任务持久化保存上游的任务 ID然后由后台的 StatusPoller 定时查询上游状态直到拿到最终结果。这两种模式选哪一种完全取决于 gpt-image-2.5 的官方文档怎么定义接入前一定要先确认不然整个管道的状态机都要重写。3.3 参数校验与内容安全检查生图接口跟普通接口还不太一样Prompt 是有安全风险的。比如用户提交一个包含违规内容的 Prompt上游接口可能直接拒绝返回 400或者返回的图存在内容合规问题。所以参数校验不能只靠 Bean Validation还要引入内容安全检查。我在接入层加了一个 PromptGuard核心逻辑是对 Prompt 做长度控制之外还会跑一个关键词/分类模型过滤。如果公司有现成的文本审核服务直接调没有的话至少做一层敏感词过滤并且对疑似风险内容返回错误提示而不是发往上游。Component public class PromptGuard { public boolean validate(String prompt) { if (prompt null || prompt.isBlank()) { return false; } if (prompt.length() 1000) { return false; } // 这里可以接第三方内容审核服务或者加载本地敏感词库 return !containsSensitiveWords(prompt); } }不要觉得这一步多此一举。内容审核不做好上游接口会拒绝你的请求、封禁你的 Key甚至影响整个账号的服务可用性。这笔账必须提前算。4. 工业级生图管道的异步处理与状态流转4.1 线程池与任务队列的参数怎么定任务提交后不能同步处理必须异步化。我用的方案是将任务写入数据库后放入一个内存队列BlockingQueue后台线程从队列里消费并执行。为什么要保留数据库因为数据库是持久化的事实源内存队列是临时调度层。如果进程挂了重启后可以把状态为排队中或生成中的任务重新捞出来继续跑这就是可靠性的兜底。线程池配置我写在这里Configuration public class TaskExecutorConfig { Bean(imageTaskExecutor) public ThreadPoolTaskExecutor imageTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(1000); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(image-gen-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }参数怎么定的核心线程 4最大 8队列 1000。计算依据是单次生图平均耗时 10~20 秒核心 4 个线程意味着每秒最多处理大概 0.2 到 0.4 个任务那 1000 的队列够不够如果一个高峰期涌入 500 个任务处理完大约需要 20 到 40 分钟队列会积压但不会立即崩。Max 8 是在瞬时流量上来时拉高吞吐但注意最大线程和队列容量是配套的队列满后才会创建新线程到 Max而不是一开始就 8 个线程跑。拒绝策略我选的 CallerRunsPolicy。这意味着队列满了之后新提交的任务会由提交任务的线程自己执行也就是说用户请求会被阻塞在提交环节。这在生图场景里其实是合理的——它告诉用户当前系统繁忙请等待而不是直接丢弃任务。从实现上看像是反压机制保护了系统不被突然的流量击垮。4.2 任务状态机与失败重试策略异步管道的核心是状态流转。画不出来图没关系但状态不能乱。我的状态机规则如下初始状态PENDING0PENDING - PROCESSING1消费线程从队列取出任务时更新PROCESSING - SUCCESS2上游调用成功拿到图片 URLPROCESSING - PENDING重试调用失败且重试次数未达上限PROCESSING - FAILED3重试次数耗尽或不可恢复的错误对应的更新 SQL 很简单但要注意状态更新的原子性。在 MySQL 里我用的乐观锁思路更新时带上当前状态条件UPDATE image_task SET status 1, updated_at NOW() WHERE task_id #{taskId} AND status 0如果影响行数为 0说明状态已经被其他线程修改了当前线程不应该继续处理。这样能防止同一个任务被多个线程重复消费。这个细节非常关键尤其是在应用多实例部署的时候不加这个条件两个实例会同时捞到同一个 PENDING 任务造成上游重复调用。重试策略我是这样设计的失败后不立即重试而是用一个带延迟的重试队列。最简单的方式是记录 next_retry_time 字段后台扫描线程定时捞取当前时间 next_retry_time 且 retry_count max_retry的任务。重试间隔可以用指数退避第一次失败后 1 分钟第二次 5 分钟第三次 30 分钟第三次是上限再失败就标记 FAILED。这个策略是针对第三方接口的不稳定性设计的不是所有失败都值得立即重试。注意重试必须区分可重试失败和不可重试失败。参数错误400、鉴权失败401、配额不足429 但为国家限流这些属于不可重试超时、502、503、连接异常属于可重试。我见过有人把所有失败都丢进重试队列结果 400 的请求重试 10 次还是 400纯粹浪费配额。4.3 结果通知轮询、回调还是 WebSocket任务完成后用户怎么拿到图片三种方式都有适合的场景我分别说下利弊。轮询最简单也最通用。客户端提交任务拿到 taskId 后每隔几秒 GET 一次任务详情状态变成 SUCCESS 后取图片 URL。问题是对服务器有一定的请求压力轮询间隔太短浪费资源太长用户感知慢。我的经验是建议轮询间隔至少 2 秒前端通过 setTimeout 递归调用来实现而不是 setInterval 高频轰炸。Webhook 回调适合服务端有回调地址的客户端。任务完成时服务端主动 POST 通知到指定的 callback URL。这种模式体验好但对客户端服务端的公网可达性有要求很多小项目没有这个条件。而且回调很容易因为网络原因丢失需要额外做重试和补偿查询。WebSocket 是前两种的折中服务端主动推送状态变更延迟最低体验最好。但需要维护长连接复杂度高一些一般用于内部管理系统。我最终的做法是轮询为主 Webhook 可选。对外暴露的接口是 RESTful 的客户端轮询最简单同时预留了 callback_url 字段在任务完成时异步调一次。这个方案兼顾了通用性和可用性。4.4 控制器的完整实现任务提交和查询的 Controller 长这样RestController RequestMapping(/api/v1/generations) public class ImageGenerationController { PostMapping public ResponseEntity? submit(Valid RequestBody ImageGenerationRequest req) { // 前置参数校验和内容审核 if (!promptGuard.validate(req.prompt())) { return ResponseEntity.badRequest().body(Map.of(error, prompt 包含违规内容)); } // 配额检查和扣减稍后详述 boolean quotaOk quotaManager.tryConsume(currentUserId, estimateCredits(req)); if (!quotaOk) { return ResponseEntity.status(429).body(Map.of(error, 配额不足)); } // 创建任务并提交到管道 ImageTask task taskService.createTask(currentUserId, req); taskQueue.submit(task); // 返回任务ID客户端凭此轮询 return ResponseEntity.accepted().body(Map.of(task_id, task.getTaskId())); } GetMapping(/{taskId}) public ResponseEntity? query(PathVariable String taskId) { ImageTask task taskService.getByTaskId(taskId); if (task null) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok(buildTaskDetail(task)); } }从这里能看到Controller 层很薄它做的事情是参数走 Bean Validation内容走 PromptGuard配额走 QuotaManager然后创建任务、丢进队列、返回 taskId。真正干活的是后台的消费线程。这就是管道化的核心思路——接口本身只负责接收意图不负责生成。Controller 层的代码和使用方式就这些了部署多实例时任务状态竞争、死信队列这些高级话题后面在排查实录里再展开。5. 防刷架构限流、配额与幂等设计5.1 防刷的几个层次入口限流、用户配额、接口防重防刷不能只靠一层拦截器挡掉一部分业务层再挡一部分最后配额系统兜底三层配合才有效。我的划分是这样的第一层是接口级别的限流。用 Redis Lua 脚本对每个用户每个接口维度做滑动窗口限流。比如一个用户每分钟最多提交 5 次生图任务超过就返回 429。这层的目的是拦住脚本的疯狂请求保护系统稳定性。第二层是用户维度的配额管理。每个用户每天最多消耗多少 Credits可以理解成积分提交任务时尝试扣减扣减成功才允许创建任务。这层管的是总量控制防止用户把一天的预算一口气刷完。第三层是幂等设计。用 Idempotency-Key 请求头用户在提交任务时带一个自己的唯一键如果同一用户带相同 Key 重复提交系统直接返回上一次的 task_id而不是创建新任务。这防止了客户端在网络超时时发起的重试被重复扣费。三层防刷缺一不可。只做限流用户今天刷完额度明天继续刷只做配额高并发下配额扣减的原子性很难保证只做幂等恶意用户通过改写 Key 照样刷爆。5.2 基于 Redis 的滑动窗口限流实现限流的算法选择上我用的滑动窗口而不是固定窗口原因是不想出现临界问题比如固定窗口在每分钟的最后一秒请求突发理论上能打到两倍上限。滑动窗口的实现我用 Lua 脚本因为要保证计数和过期检查是原子的。-- 参数: key, windowStart, windowSize, maxRequests local key KEYS[1] local currentTime tonumber(ARGV[1]) local windowSize tonumber(ARGV[2]) local maxRequests tonumber(ARGV[3]) -- 移除窗口之前的计数 redis.call(ZREMRANGEBYSCORE, key, 0, currentTime - windowSize) -- 统计当前窗口内请求数 local count redis.call(ZCARD, key) if count maxRequests then return 0 end redis.call(ZADD, key, currentTime, currentTime) redis.call(PEXPIRE, key, windowSize) return 1这样每次请求进来脚本先清理窗口外的旧数据再统计当前窗口内的数量达到上限就拒绝。ZSET 里的 score 和时间戳member 用时间戳是因为同一毫秒可能会有重复成员实际使用时我会拼上随机后缀保证唯一性。执行逻辑public boolean allow(String userId, String action) { long now System.currentTimeMillis(); ListString keys List.of(rate: userId : action); Long result redisTemplate.execute(rateLimitScript, keys, String.valueOf(now), String.valueOf(60000), String.valueOf(5)); return result ! null result 1; }参数1000ms…我写的是 60000 窗口、5 次上限只是个示范实际要按你的业务量调。比如提交生图任务限流 5 次/分钟查询任务详情限流 60 次/分钟因为查询是轻量操作频次可以放宽。注意如果你的 Redis 是集群模式Lua 脚本里用到的 key 必须保证在同一个哈希槽。最简单的方法是对同一个用户限流时只用一个 key像上面这样天然在同一个 slot。如果脚本涉及多个 key就要用哈希标签 {} 来强制路由。5.3 配额扣减的原子性问题配额扣减比限流更敏感因为涉及钱。不能用先检查再扣减的非原子操作——在高并发下两个请求同时读到余额充足同时扣减最后超卖。推荐的做法是用 Redis 的 DECR 一次性扣减。但单纯 DECR 有两个问题余额可能被扣成负数以及不知道扣之前的余额余多少。所以用 Lua 保证判断与扣减的一体性-- 参数: key, cost, minQuota local key KEYS[1] local cost tonumber(ARGV[1]) local current tonumber(redis.call(GET, key) or 0) if current cost then return -1 end redis.call(DECRBY, key, cost) return current - cost这段逻辑的意思很直白——余额不够就返回 -1不扣减够就扣掉并返回剩余余额。它把检查余额和扣减余额合并成了一个原子操作彻底消灭了超卖问题。配额的数据结构我用的是 Redis Hash每个用户一个 keyquota:user:{userId}field 是 date比如 2025-01-15value 是剩余配额每天自动过期。之所以不用定时任务清零而是用带日期的 field是因为 Redis 的过期时间是以 key 为单位的用 field 维度天然支持多天数据共存也方便追溯历史消耗。配额在生图任务真正成功时才是最终扣减但提交时预占配额失败后归还。这个设计很重要不然失败重试会重复扣费。5.4 幂等设计与签名机制开放平台场景如果你的接口是面向第三方的开放 API那防刷还要再提高一个档次。除了限流和配额外还得有身份认证和防篡改签名。经典做法是 appKey appSecret 机制。客户端调用接口时带四个参数appKey身份标识、timestamp时间戳、nonce随机字符串、sign签名。签名是对 appKey timestamp nonce 业务参数按约定拼接后做 HMAC-SHA256。服务端验证appKey 是否存在、是否被禁用timestamp 与服务器时间差是否超过 5 分钟防重放nonce 是否被使用过用 Redis SETNX比如 10 分钟过期防重放sign 是否一致防篡改签名校验放在拦截器里没有正确的签名请求根本不进 Controller。这一步对开放平台是必须的否则别人只要拿到你的 Base URL 就能直接刷。幂等这一层我单独说一个细节Idempotency-Key 头应该由用户生成服务端同一用户同一 Key 只能创建一次任务。实现上在 image_task 表加唯一索引user_id, idempotency_key创建任务时如果触发了唯一键冲突就查出已有任务返回。这种方式是在数据库层面保证幂等比查询再判断更可靠。6. 常见问题与排查实录6.1 任务一直卡在排队中消费线程没跑这是个高频问题。现象是任务表里 status0 的记录越来越多但后台日志里看不到消费记录。排查路径先看线程池状态是不是核心线程全部挂掉了我遇到过一次是 HTTP 客户端的连接池被打满所有消费线程阻塞在获取连接上看起来就是任务进了队列但没人处理。定位方法jstack 抓线程栈看到所有线程阻塞在 HttpClient 的连接池获取上基本就是连接池参数和生图耗时之间的平衡没算好调大连接池或降低并发就能缓解。另一种常见原因是数据库连接池太小。消费线程每处理一个任务都要更新数据库如果数据源连接被别的接口占满也会出现类似卡住的表现。这种坑在压测环境最容易爆生产环境反而少见因为业务量没那么大。6.2 上游返回 429自己的限流策略反而先触发了很多人对接第三方 AI 接口时优先考虑我自己别被打崩结果忽略了上游也有自己的限流。如果 gpt-image-2.5 的限流是每分钟 60 次请求你自己的本地限流如果设置得太宽比如每分钟 200 次那么冲到上游的第 61 个请求就会被打回来。 对方返回 429 并带着 Retry-After 响应头时一定要读取并尊重它。我的做法是解析 Retry-After 的值把该任务丢回重试队列并设置 next_retry_time now retryAfter 500ms 的缓冲。更主动的办法是提前做并发控制。我习惯在消费线程上加一个轻量的 Semaphore限制同时进行中的上游调用数量不超过上游限流阈值的 80%给上游留点余量。这个缓冲很重要能明显减少 429 和 5xx。6.3 重试机制导致任务重复扣费这是最肉疼的坑。我最早实现失败重试时用了 HTTP 层的自动重试。上游接口真的超时了但请求可能其实已经到达服务器并且完成了生成只是响应体没回来HTTP 层再次重试就等于让上游生成了两张图但你的业务表里只记录了第一次调用的失败状态。这一点我在设计时如何处理HTTP 层的重试必须关掉或者只用于连接失败场景真正的重试放在业务层用任务表的状态和 retry_count 控制。每次调用前生成一个幂等请求 IDUUID放进 body 里发给上游如果上游支持 Idempotency-Key 头就带上。下次重试同一任务的请求用同一个 ID上游就能识别出这是同一个请求拒绝重复处理或者返回相同结果。但要注意gpt-image-2.5 这类接口不一定支持幂等头所以至少要做到重试前确认上一次调用确实失败。比如超时后不要立刻重试先调用查询接口确认该次请求在远端是否成功如果成功就直接把结果捞回来失败才重试。这个确认型重试虽然慢一点但保住了配额。6.4 多实例部署时任务被重复消费应用从单实例扩到多实例后任务表方案最大的问题暴露了两台机器同时去捞 PENDING 任务可能捞到同一个任务。我前面提到过使用 UPDATE 语句带状态条件来抢占。这里再补充完整方案。每台机器的消费线程从数据库里捞任务时不能直接 select要使用原子性抢占UPDATE image_task SET status 1, updated_at NOW(), retry_count IF(status 0, retry_count, retry_count) WHERE task_id #{taskId} AND status 0如果影响行数为 1说明这台实例抢到了任务可以继续。如果为 0说明已经被别的实例抢走直接跳过。这个方案没有引入分布式锁就靠一条带条件的 UPDATE简单且可靠。等到任务真正返回成功后再把状态改成 2失败则改回 0 进入重试。6.5 常见问题速查表我直接把半年里遇到最多的几个问题整理成表格方便你对照排查。现象可能原因排查与解决任务提交后长时间排队中消费线程被阻塞或线程池配置过小查看线程池活动线程数jstack 定位是否卡在 HTTP/数据库连接大量请求返回 429限流阈值设置不合理调大滑动窗口或上限但注意不能超过上游限额上游频繁 5xx触发了对方限流阈值用 Semaphore 控制并发到上游阈值的 80%留出缓冲同一任务重复生成重试时请求 ID 不一致使用幂等请求 ID重试确认机制配额扣成负数扣减非原子使用 Lua 脚本合并检查与扣减多实例重复消费没有用状态条件抢占UPDATE ... WHERE status0影响行数判断图片 URL 访问超时文件存储或 CDN 回流慢上传到自己的 OSS/COS 或 CDN不要直接回源引用7. 上线后的几个建议讲完核心代码和排查实录最后分享几条我个人在上线后体会特别深的事情。第一日志和监控一定要早做。管道类系统最怕盲人摸象任务卡在哪一层、上游慢不慢、重试占了多少这些不看监控根本不知道。我在每个关键节点加了埋点任务提交、消费开始、上游调用耗时、状态流转、结果返回。各阶段的耗时分布一眼能看到瓶颈上游接口性能下降也能第一时间发现。第二配额审计不能省。每次任务消耗了多少 Credits用户剩余多少任务成功后实际扣了多少要有完整记录。不然月底对账时用户说我没生成几张图凭什么扣这么多你什么都拿不出来。我在 image_task 表里保留了 cost_credits 字段再配合一张配额流水表每一笔扣减都有据可查。第三图片结果要管理起来。第三方接口返回的 URL 有时效性或者直接被对方 CDN 托管的图片随时可能失效。上线第一天我就发现了这个问题用户的图片第二天就 404 了。解决方案是任务成功后后台立即把图片下载下来转存到自己的 OSS/COS再在数据库里更新为内部 URL。这一步虽然增加了一些开发量但长期价值很大图片可控、访问速度可控、还能做合规审核。第四提示词的未来再生成价值很高。用户提交过的 prompt 和生成的图片配对后是很有价值的数据资产可以做灵感库相似推荐等上层功能。所以数据库表结构设计时尽量预留扩展字段和关联表的空间别把逻辑写死在单个表里。这套管道和防刷架构跑下来实测一个高峰期几千个任务进来系统稳定、没有超卖、没有重复生成账单也完全可控。整体代码量不大但每一行都有它存在的理由这就是工业级和 demo 的区别。如果你按照这篇的思路去接 gpt-image-2.5 或者往后的各类图像模型接口核心骨架是不变的异步管道保证可靠性状态机控制生命周期配额和限流守住成本边界。把这三点抓住你的 AI 生图能力就能从能跑进化到能扛事。