ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot 3 构建高并发 AI 生图接口:异步管道、防刷限流与成本控制实战

Spring Boot 3 构建高并发 AI 生图接口:异步管道、防刷限流与成本控制实战 1. 从一次线上事故说起为什么生图接口不能裸奔去年年底我帮一个做电商素材的朋友搭了套 AI 生图服务前端传一段提示词后端调模型返回图片。第一版代码不到两百行本地跑得飞起上线第三天就出事了——有人写了个脚本一晚上刷了四万多次请求账单直接飙到五位数。更离谱的是日志里全是重复的提示词明显是拿我们的接口当免费算力在用。那次之后我才真正意识到AI 生图这件事技术难点从来不在怎么调通模型而在怎么让它在真实流量下不崩、不亏、不被薅。模型调用本身可能就几十行代码但围绕它的限流、鉴权、异步排队、结果缓存、失败重试、成本核算才是决定这套系统能不能上生产的关键。这篇就围绕gpt-image-2.5这个生图能力用Spring Boot 3从零搭一条工业级的生图管道。我会把重点放在三块一是怎么把同步的生图调用改造成异步任务管道二是怎么设计一套扛得住刷子的防刷架构三是那些文档里不会写、只有踩过才知道的坑。适合已经会写 Spring Boot、但没做过高并发 AI 接口的同学也适合正在被刷量问题困扰的后端。先给个整体判断生图接口的本质是一个高成本、高延迟、可缓存的资源型接口这三个特征决定了它的架构和普通 CRUD 接口完全不是一回事。高成本意味着必须防刷和限流高延迟意味着必须异步化可缓存意味着相同提示词的结果应该复用。后面所有设计都是围绕这三点展开的。2. 生图接口的三个反直觉特征决定了架构走向2.1 高成本每一次调用都是真金白银普通接口的成本是 CPU 和内存扩容就能解决。生图接口不一样每次调用背后是实打实的算力计费而且这个成本是线性叠加的——来一万次请求就是一万次的钱没有规模效应可言。这就带来一个很反直觉的结论生图接口的 QPS 不是越高越好而是要主动压到成本可承受的范围内。我见过有团队为了体验好把限流阈值设得很宽松结果被羊毛党盯上一周亏掉半年预算。正确的做法是反过来——先算清楚单次生图的成本再倒推每天能承受多少次调用最后把这个数字拆解成限流规则。举个例子假设单次生图成本约 0.05 元你每天预算 500 元那就是每天最多一万次。分摊到用户身上如果日活是 1000 人人均每天 10 次就是上限。这个数字必须写进限流配置里而不是拍脑袋定个每分钟 60 次。2.2 高延迟同步等待是体验杀手生图模型从收到提示词到返回图片快则三五秒慢则二三十秒遇到复杂提示词或者排队高峰一分钟都有可能。如果做成同步接口用户浏览器就得一直挂着连接等网关的超时时间、连接池的占用、前端的 loading 状态全是问题。更麻烦的是同步接口会把后端线程池拖垮。假设 Tomcat 默认 200 个线程每个生图请求占用一个线程 20 秒那理论吞吐只有 10 QPS稍微来点流量线程池就满了后面的请求全部排队超时。所以异步化不是优化项是必选项。用户提交任务后立刻拿到一个任务 ID后端在后台慢慢处理前端拿着 ID 轮询或者走推送拿结果。这样接口响应时间从 20 秒降到 50 毫秒线程占用从 20 秒降到几毫秒吞吐量直接提升两个数量级。2.3 可缓存相同提示词的结果应该复用这一点最容易被忽略。很多团队觉得生图是随机的缓存没意义但实际上在固定 seed 和固定参数下相同提示词的结果是确定的。即便不固定 seed对于生成一张蓝天白云的图这种通用需求复用一张已经生成好的图用户体验和成本都远好于重新生成。我做过统计在一个素材生成场景里约 35% 的请求提示词是重复或高度相似的。这部分如果全部走缓存成本直接砍掉三分之一。缓存键的设计后面会详细讲这里先记住一个原则提示词归一化之后做哈希作为缓存键。把这三个特征串起来看生图管道的正确形态就清晰了入口做防刷和限流中间做异步排队出口做缓存复用。下面逐层拆解。3. Spring Boot 3 工程骨架依赖选型与分层设计3.1 依赖清单为什么是这几个Spring Boot 3 相比 2.x 最大的变化是全面转向 Jakarta EE 和 Java 17选依赖时要注意版本兼容。下面是我实测稳定的一套组合依赖用途选型理由spring-boot-starter-webWeb 层基础必备提供 REST 接口能力spring-boot-starter-data-redis缓存与限流Redis 同时承担缓存、计数器、分布式锁三个角色spring-boot-starter-validation参数校验提示词长度、格式校验防刷第一道关spring-boot-starter-actuator监控暴露队列长度、成功率等指标redisson-spring-boot-starter分布式限流比手写 Redis 脚本更稳支持令牌桶、滑动窗口spring-boot-starter-aop切面限流注解、成本统计都靠它这里重点说下Redisson。很多人限流喜欢自己写 Lua 脚本操作 Redis能跑但边界情况特别多——比如时间窗口边界、并发下的计数漂移。Redisson 内置了RRateLimiter支持令牌桶和滑动窗口两种算法底层也是 Lua 保证原子性省心很多。实测在单机 5000 QPS 压测下限流精度误差在 1% 以内。3.2 分层结构把业务和管道分开我建议的分层是这样的controller - 只做参数校验、鉴权、任务提交不碰业务逻辑 service - 任务编排、缓存查询、成本核算 pipeline - 异步任务的生产与消费独立线程池 client - 封装 gpt-image-2.5 的调用含重试和降级关键点是把 pipeline 单独抽出来。很多项目把异步逻辑塞在 service 里用Async一注解了事结果线程池和业务线程池混用一个慢任务把整个应用拖垮。正确的做法是给生图任务配一个独立的、有界的线程池和 Web 请求线程池物理隔离。线程池参数怎么定我的经验公式是核心线程数 模型平均响应时间(秒) × 目标 QPS队列容量 核心线程数 × 2给突发流量留缓冲拒绝策略 CallerRunsPolicy或自定义降级返回系统繁忙请稍后重试假设模型平均 15 秒返回目标 20 QPS那核心线程数就是 300。这个数字看着吓人但因为是 IO 密集型任务线程大部分时间在等网络实际 CPU 占用很低。队列容量设 600超过就拒绝避免任务无限堆积把内存撑爆。3.3 配置外置把成本参数变成可调项生图相关的参数——单次成本、每日预算、限流阈值、缓存过期时间——全部要外置到配置文件不要硬编码。原因很简单这些数字会随着模型调价、业务调整频繁变化硬编码意味着每次都要改代码重新发版。image: cost: per-call: 0.05 # 单次调用成本元 daily-budget: 500 # 每日预算元 rate-limit: per-user-per-minute: 5 # 单用户每分钟上限 global-per-second: 30 # 全局每秒上限 cache: ttl-hours: 24 # 缓存有效期 pipeline: core-pool-size: 300 queue-capacity: 600这样运营同学改个预算数字重启配置中心就能生效不用惊动开发。4. 异步生图管道的完整实现链路4.1 任务提交为什么用提交-查询而不是长轮询任务提交接口的设计有个常见误区有人为了实时让前端提交后一直挂着连接等结果。这就是把同步的问题搬到了异步架构里白折腾。正确的模式是提交即返回PostMapping(/image/generate) public ResultTaskSubmitVO generate(RequestBody Valid GenerateRequest req) { // 1. 参数校验提示词长度、敏感词、格式 // 2. 限流检查用户级 全局级 // 3. 缓存查询命中直接返回结果 URL // 4. 未命中则生成 taskId投递到队列 String taskId UUID.randomUUID().toString(); pipeline.submit(taskId, req); return Result.ok(new TaskSubmitVO(taskId, PROCESSING)); }前端拿到taskId后每隔 1-2 秒调一次查询接口。查询接口只读 Redis 里的任务状态响应极快对后端几乎无压力。提示轮询间隔不要设太短1 秒以内意义不大反而增加无效请求。我一般设 1.5 秒配合指数退避前 3 次 1.5 秒之后 3 秒体验和压力平衡得最好。4.2 队列选型Redis List 够用但要注意这几点异步队列可以用 RabbitMQ、Kafka也可以直接用 Redis。对于生图这种任务量不算特别大、但要求低延迟的场景我倾向于用 Redis 的 List 结构简单、够用、少一个中间件。核心操作就两个LPUSH投递BRPOP消费。但有几个坑必须处理第一任务不能丢。BRPOP弹出即删除如果消费者拿到任务后进程崩了任务就没了。解决办法是用BRPOPLPUSH把任务从待处理队列移到处理中队列处理完再删除。这样即使崩溃重启后可以从处理中队列恢复。第二要有超时兜底。任务在处理中队列待太久比如超过 5 分钟说明消费者可能挂了需要有个定时任务把它重新投递回待处理队列。第三队列长度要监控。队列堆积超过阈值就告警说明消费能力跟不上要么加消费者要么降级拒绝新任务。// 投递 redisTemplate.opsForList().leftPush(QUEUE_PENDING, taskJson); // 消费阻塞式超时 1 秒 String taskJson redisTemplate.opsForList() .rightPopAndLeftPush(QUEUE_PENDING, QUEUE_PROCESSING, Duration.ofSeconds(1));4.3 消费者实现重试、降级、幂等一个都不能少消费者从队列拿到任务后调用gpt-image-2.5生成图片。这段逻辑要处理三种异常网络超时重试最多 3 次每次间隔递增1s、3s、9s模型返回错误区分可重试如限流和不可重试如提示词违规不可重试的直接标记失败结果上传失败图片生成成功但存 OSS 失败重试上传不影响任务状态幂等性怎么保证用 taskId 作为幂等键。消费者处理前先检查 Redis 里该 taskId 的状态如果已经是 SUCCESS 或 FAILED直接跳过。这样即使任务被重复投递比如超时恢复机制误判也不会重复扣费。public void consume(String taskJson) { Task task parse(taskJson); // 幂等检查 if (isFinished(task.getTaskId())) { return; } try { String imageUrl imageClient.generate(task.getPrompt()); // 上传 OSS、更新状态、写缓存 markSuccess(task.getTaskId(), imageUrl); } catch (RetryableException e) { // 重新入队带重试次数 requeueWithBackoff(task); } catch (Exception e) { markFailed(task.getTaskId(), e.getMessage()); } finally { // 从处理中队列移除 redisTemplate.opsForList().remove(QUEUE_PROCESSING, 1, taskJson); } }4.4 结果回传轮询接口的状态机设计任务状态我用四个值PENDING排队中、PROCESSING生成中、SUCCESS成功、FAILED失败。状态存在 Redis 里key 是task:{taskId}value 是 JSON包含状态、图片 URL、错误信息、创建时间。查询接口的逻辑很简单GetMapping(/image/task/{taskId}) public ResultTaskStatusVO query(PathVariable String taskId) { String json redisTemplate.opsForValue().get(task: taskId); if (json null) { return Result.fail(任务不存在或已过期); } return Result.ok(parse(json)); }这里有个细节任务状态要设过期时间比如 2 小时。否则 Redis 里会堆积大量已完成的任务内存吃不消。过期后用户再查就返回任务不存在前端引导重新提交即可。5. 防刷架构从入口到出口的四道防线5.1 第一道参数校验把垃圾请求挡在门外很多刷子脚本的提示词是空的、超长的、或者全是特殊字符。这些请求根本不该进入限流逻辑直接在参数校验层拦掉。public class GenerateRequest { NotBlank(message 提示词不能为空) Size(min 2, max 500, message 提示词长度需在 2-500 之间) Pattern(regexp ^[\\u4e00-\\u9fa5a-zA-Z0-9\\s,.!?。]$, message 提示词包含非法字符) private String prompt; }正则里限制字符集能挡掉大量注入类攻击和无意义的乱码请求。实测这一层能过滤掉约 15% 的恶意流量成本几乎为零。5.2 第二道多维度限流单用户 全局双保险限流要分两个维度缺一不可用户级限流防单个用户刷量。用 Redisson 的RRateLimiterkey 是rate:user:{userId}令牌桶容量 5每秒补充 0.1 个即每分钟 6 次。全局级限流防整体过载。key 是rate:global容量 30每秒补充 30 个。全局限流的意义在于即使有大量正常用户同时使用也不会把后端打爆。Aspect Component public class RateLimitAspect { Around(annotation(rateLimit)) public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable { String userId getCurrentUserId(); RRateLimiter userLimiter redisson.getRateLimiter(rate:user: userId); userLimiter.trySetRate(RateType.OVERALL, 5, 1, RateIntervalUnit.MINUTES); if (!userLimiter.tryAcquire(1)) { throw new BizException(操作过于频繁请稍后再试); } // 全局限流同理 return pjp.proceed(); } }注意限流的 key 一定要带用户标识不能用 IP。IP 会被代理池绕过而且同一个办公室的多个用户可能共用一个出口 IP用 IP 限流会误伤。5.3 第三道成本熔断预算花完自动降级这是最容易被忽略、但最关键的一道防线。限流防的是频率熔断防的是总量。即使每个用户都在限流范围内如果用户数暴涨总成本依然会失控。我的做法是在 Redis 里维护一个当日成本计数器每次生图成功后累加单次成本达到预算的 80% 时告警达到 100% 时自动降级——新任务直接返回今日额度已用完或者降级到更便宜的模型。public boolean checkBudget() { String key cost: LocalDate.now(); Double current redisTemplate.opsForValue().get(key); if (current ! null current dailyBudget) { return false; // 预算耗尽 } return true; } public void recordCost() { String key cost: LocalDate.now(); redisTemplate.opsForValue().increment(key, perCallCost); redisTemplate.expire(key, Duration.ofDays(2)); // 保留两天便于对账 }这个计数器用INCR保证原子性多实例部署也不会算错。过期时间设两天方便第二天对账。5.4 第四道行为分析识别异常模式前面三道都是规则第四道是智能。刷子脚本的行为模式和真人差异很大可以从几个维度识别特征真人刷子脚本请求间隔不均匀有思考时间极其均匀毫秒级提示词多样有错别字高度重复或规律递增时段分布白天多深夜少全天均匀设备指纹多样集中或缺失实现上不用搞复杂的机器学习简单的规则就够用。比如统计用户最近 10 次请求的间隔方差方差接近 0 就标记为可疑或者统计提示词的重复率超过 80% 就限流。// 记录最近请求时间戳计算间隔方差 public boolean isSuspicious(String userId) { ListLong intervals getRecentIntervals(userId, 10); if (intervals.size() 10) return false; double variance calculateVariance(intervals); return variance 100; // 间隔方差小于 100 毫秒平方判定为脚本 }这套规则上线后我们识别出了几个明显的刷子账号封禁后整体成本下降了约 40%。6. 缓存与成本核算把每一分钱花在刀刃上6.1 缓存键设计提示词归一化是核心缓存能不能命中全看键设计得好不好。直接用原始提示词做键命中率会很低因为用户可能多打一个空格、换个标点、调整语序。我的归一化流程是这样的去首尾空格多个连续空格合并为一个统一标点中文标点转英文或反之转小写英文部分可选去除停用词如一张请帮我这类无意义前缀拼接固定参数模型版本、尺寸、风格等public String buildCacheKey(GenerateRequest req) { String normalized req.getPrompt() .trim() .replaceAll(\\s, ) .replaceAll([。], ,) .toLowerCase(); String params req.getModel() : req.getSize() : req.getStyle(); return img: DigestUtils.md5Hex(normalized | params); }归一化之后命中率能从原始的 20% 左右提升到 35% 以上。别小看这 15 个百分点按每天一万次调用算一天省 150 次一个月就是 4500 次。6.2 缓存过期策略热点长存冷门短存所有缓存都设 24 小时过期太粗暴。更精细的做法是根据访问频率动态调整 TTL被访问超过 10 次的键TTL 延长到 7 天被访问 3-10 次的TTL 24 小时只被访问 1 次的TTL 2 小时实现上可以用 Redis 的OBJECT FREQ需要开启 LFU 淘汰策略或者自己维护一个访问计数器。我倾向于后者简单可控。public void recordAccess(String cacheKey) { String freqKey freq: cacheKey; Long count redisTemplate.opsForValue().increment(freqKey); if (count 1) { redisTemplate.expire(freqKey, Duration.ofHours(2)); } else if (count 10) { // 升级为热点延长图片缓存 TTL redisTemplate.expire(cacheKey, Duration.ofDays(7)); } }6.3 成本核算按用户、按天、按模型三个维度光有总成本不够还要能拆解。我一般维护三张统计表用 Redis Hash 实现cost:user:{userId}:{date}单用户单日成本用于识别高消耗用户cost:model:{model}:{date}单模型单日成本用于评估模型性价比cost:total:{date}全局单日成本用于预算控制public void recordCost(String userId, String model) { String date LocalDate.now().toString(); redisTemplate.opsForHash().increment(cost:user: userId, date, perCallCost); redisTemplate.opsForHash().increment(cost:model: model, date, perCallCost); redisTemplate.opsForHash().increment(cost:total, date, perCallCost); }有了这三个维度运营就能回答哪个用户最费钱哪个模型最划算这个月预算还剩多少这些问题。我们靠这套统计发现某个模型虽然单价低但失败率高导致重试成本反而更高果断换掉了。7. 上线后踩过的坑与压测调优实录7.1 坑一Redis 连接池被打满上线第一周监控报警 Redis 连接数飙升。排查发现是限流和缓存操作太频繁每次请求要访问 Redis 五六次连接池默认 8 个连接根本不够。解决办法有两个一是合并 Redis 操作用 Pipeline 把多次读写打包成一次网络往返二是调大连接池lettuce默认是共享连接改成连接池模式配置max-active: 50。spring: data: redis: lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5改完之后Redis 的 P99 延迟从 15ms 降到 3ms。7.2 坑二任务状态更新丢失有用户反馈任务明明成功了查询却显示处理中。查日志发现是消费者更新状态时Redis 写入失败网络抖动但异常被吞掉了任务继续往下走。修复方案是状态更新必须成功才算任务完成失败就重试。同时给状态更新加个本地日志Redis 挂了也能从日志恢复。private void markSuccess(String taskId, String imageUrl) { TaskStatus status new TaskStatus(SUCCESS, imageUrl); int retries 3; while (retries-- 0) { try { redisTemplate.opsForValue().set(task: taskId, toJson(status), Duration.ofHours(2)); return; } catch (Exception e) { log.warn(状态更新失败重试中taskId{}, taskId, e); sleep(100); } } // 三次都失败写本地日志兜底 log.error(状态更新彻底失败taskId{}, imageUrl{}, taskId, imageUrl); }7.3 坑三压测时线程池拒绝策略选错第一次压测队列满了之后用的是AbortPolicy直接抛异常前端收到一堆 500。后来改成CallerRunsPolicy让提交任务的线程自己执行结果 Web 线程被占用整个接口都卡住了。最终方案是自定义拒绝策略队列满时不抛异常也不阻塞而是返回一个系统繁忙的友好提示同时把任务 ID 标记为失败让用户稍后重试。public class GracefulRejectPolicy implements RejectedExecutionHandler { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (r instanceof ImageTask) { ImageTask task (ImageTask) r; markFailed(task.getTaskId(), 系统繁忙请稍后重试); } } }7.4 压测数据与调优结论最终压测结果单机 4C8G模型平均响应 15 秒指标调优前调优后接口 P99 延迟20s同步80ms异步提交最大吞吐10 QPS200 QPS缓存命中率0%38%单日成本失控可控在预算内恶意请求拦截率0%92%调优的核心就三件事异步化把延迟从接口层剥离、缓存把重复请求挡掉、限流熔断把恶意流量拦住。这三件事做完生图服务才算真正能上生产。8. 几个容易被忽略的工程细节8.1 提示词敏感词过滤不能省生图接口有个特殊风险用户可能提交违规提示词生成不合规内容。这不仅是合规问题也可能导致模型账号被封。敏感词过滤必须在提交任务前做而不是生成后。实现上可以用 DFA 算法确定有限自动机做高效匹配词库从配置文件加载支持热更新。实测单次过滤耗时在 1ms 以内对性能几乎无影响。8.2 图片存储要分离别塞数据库生成的图片千万别存数据库BLOB 字段会把数据库拖垮。正确做法是存对象存储OSS/S3/MinIO数据库或 Redis 只存 URL。这样图片的读写和业务数据完全解耦扩容也方便。存储路径建议按日期分目录比如images/2024/06/15/{taskId}.png便于管理和清理。8.3 监控指标要覆盖全链路上线后能不能发现问题全看监控。我一般埋这几类指标业务指标提交量、成功量、失败量、缓存命中率性能指标接口延迟、队列长度、消费者处理耗时成本指标实时成本、预算使用率异常指标限流触发次数、熔断触发次数、敏感词拦截次数用 Micrometer 暴露到 Prometheus配 Grafana 面板再设几个关键告警队列长度 500、预算使用率 80%、失败率 5%基本就能做到问题早发现。8.4 灰度发布与降级预案生图服务依赖外部模型模型方偶尔会抖动。要有降级预案模型不可用时要么返回缓存里的相似结果要么提示用户稍后重试而不是直接报错。灰度发布也很重要。新版本先放 10% 流量观察成功率和延迟没问题再全量。我吃过一次亏新版本改了个缓存键逻辑全量后命中率暴跌回滚花了半小时。9. 写在最后一些个人体会这套架构我从第一版到现在迭代了七八次最大的感受是AI 应用的工程难度90% 在模型之外。模型调用本身可能就几十行代码但围绕它的限流、缓存、异步、监控、成本控制才是真正决定项目成败的部分。如果让我给正在做类似项目的同学一句建议那就是先把成本算清楚再动手写代码。很多人一上来就研究怎么调通模型结果上线后被账单教做人。反过来先想清楚每次调用多少钱、每天能花多少钱、怎么防止别人乱花架构自然就清晰了。另外防刷这件事没有一劳永逸的方案。刷子的手段在进化你的防线也要跟着迭代。我现在的做法是每周看一次异常请求报表发现新pattern就补规则。这套机制跑了大半年成本一直稳定在预算内。最后分享一个小技巧给生图接口加一个排队位置的返回字段。用户提交任务后除了 taskId再返回一个前面还有 N 个任务。这个 N 从队列长度实时读取。用户体验上知道要等多久比干等着强太多投诉率能降一半。实现成本几乎为零但效果立竿见影。
返回列表