ARTICLE DETAIL

资讯详情

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

Spring Boot 3 构建工业级 AI 生图管道:异步、限流与防刷实战

Spring Boot 3 构建工业级 AI 生图管道:异步、限流与防刷实战 1. 为什么要在 Spring Boot 3 里做 AI 生图管道1.1 从一次线上事故说起去年年底我接手了一个内部创意工具平台核心功能是让运营同学输入一段中文描述后台调用 AI 生图模型返回图片用于活动海报、商品主图、社媒配图这些场景。最初版本是运营同学自己在某个网页端手动生成再下载上传到素材库效率低不说风格还完全不统一。后来我们把它做成了内部系统前端一个输入框后端直接对接生图接口。上线第一周就出事了。某个运营同学写了个脚本循环调用我们的接口批量生成图片一晚上跑了三万多张账单直接爆掉同时因为同步阻塞调用整个服务的线程池被打满其他业务接口全部超时。那次事故之后我才意识到AI 生图这件事技术难点根本不在怎么调通接口而在于怎么把它做成一条可控、可限流、可观测的工业级管道。这也是我写这篇东西的原因。市面上讲 AI 生图的文章绝大多数停留在申请 key、发个请求、拿到图片 URL这个层面但真正把它放到生产环境里你会发现一堆问题同步调用把 Tomcat 线程占满、用户疯狂刷单、生成失败没有重试、图片存哪、怎么计费、怎么防止 prompt 注入……这些才是真正吃经验的地方。1.2 技术选型的几个关键判断先说为什么是 Spring Boot 3 而不是 Python 那套。很多人第一反应是AI 相关的东西不是应该用 Python 吗这个认知其实有偏差。Python 在模型训练、推理脚本、算法实验上确实无可替代但生图接口的调用本质上是一次 HTTP 请求编排它考验的是你的 Web 框架在高并发下的稳定性、事务管理、连接池、限流熔断这些工程能力。这块 Java 生态尤其是 Spring Boot 3 配合虚拟线程优势非常明显。Spring Boot 3 有几个点是我特别看重的虚拟线程Virtual ThreadsJDK 21 正式落地Spring Boot 3.2 之后一行配置就能开启。生图接口是典型的 IO 密集型任务一次调用动辄十几秒用虚拟线程可以把线程占用成本降到极低这是传统平台线程做不到的。原生支持可观测性Micrometer Actuator 开箱即用生图这种长耗时任务没有指标监控基本等于裸奔。声明式限流与重试生态成熟Resilience4j、Bucket4j 这些库和 Spring Boot 3 集成度很高防刷架构不用自己造轮子。至于异步管道我选的是Spring 的Async 线程池 数据库任务表这套组合而不是一上来就上 MQ。原因很简单初期量级没到那个份上引入 Kafka 或 RocketMQ 会增加运维成本和排查难度。用数据库任务表做状态机配合定时补偿足够撑到日均十万张的量级。等真的到了瓶颈再换 MQ迁移成本也不高。1.3 这条管道到底要解决什么问题把需求拆开看一条工业级 AI 生图管道至少要解决下面这几件事问题朴素做法工业级做法调用耗时同步等待返回异步任务 轮询/回调用户刷单无限制多维度限流 配额生成失败直接报错分级重试 降级图片存储存本地磁盘对象存储 CDN成本控制事后看账单实时计量 预算熔断内容安全不处理prompt 审核 结果审核可观测性打日志指标 链路追踪 告警这张表基本就是整篇文章的骨架。下面我会一层一层拆开讲每个环节都会给出可落地的代码和参数以及我在实际踩坑之后总结出来的经验。2. 核心架构设计与模块拆解2.1 整体分层接入层、编排层、执行层、存储层我最终落地的架构分成四层这个分层不是拍脑袋定的而是根据职责边界和故障隔离两个原则划出来的。接入层负责接收用户请求、鉴权、参数校验、限流。这一层必须极快不能有任何阻塞操作所有耗时逻辑全部往后丢。它的核心职责是快速拒绝把不合法的、超额的请求挡在门外。编排层是整条管道的大脑负责把一次生图请求拆解成任务、写入任务表、调度执行、管理状态流转。它不直接调用生图接口而是通过任务状态机来驱动。执行层是真正干活的地方由一组异步 worker 组成从任务表里捞取待执行任务调用 AI 生图接口处理返回结果上传图片回写状态。这一层是 IO 密集型的用虚拟线程池最合适。存储层包括任务表MySQL、图片对象存储、以及缓存Redis用于限流计数和幂等。分层的好处是故障隔离。比如生图接口挂了接入层依然能正常接收请求并返回任务已排队用户体验不会崩执行层可以独立扩容不影响其他层。2.2 任务状态机整条管道的心脏任务状态机是我认为整个设计里最值得花心思的部分。一个生图任务从创建到完成会经历这些状态CREATED - QUEUED - RUNNING - SUCCEEDED | - FAILED - RETRYING - RUNNING | - FAILED_FINAL每个状态流转都要落库并且带上时间戳和操作人系统或用户。这样做的好处是可追溯任何一张图什么时候生成的、重试了几次、失败原因是什么一查便知。可补偿定时任务扫描长时间停留在 RUNNING 的任务判定为超时并重新入队。可计费只有 SUCCEEDED 的任务才计入配额消耗避免用户为失败任务买单。这里有个坑我要提前说状态流转一定要用乐观锁或者UPDATE ... WHERE status ?这种带条件的更新否则并发场景下会出现同一个任务被两个 worker 同时执行的情况。我一开始没注意结果同一张图生成了两次白白浪费了两次调用额度。2.3 为什么用数据库任务表而不是直接上 MQ这个问题我被问过很多次我的回答是看你的量级和团队规模。数据库任务表的优势在于实现简单、事务一致性强、排查方便直接查表就能看到所有任务状态、不需要额外运维中间件。缺点是轮询有延迟、高并发下数据库压力大。MQ 的优势是吞吐高、解耦彻底、天然支持削峰。缺点是引入运维复杂度、消息丢失和重复消费需要额外处理、排查链路变长。我的判断标准是日均任务量低于 50 万数据库任务表完全够用。超过这个量级或者对延迟有极高要求比如要求 1 秒内开始执行再考虑上 MQ。我现在的系统日均 8 万张左右数据库任务表跑得很稳单表数据量控制在 500 万以内配合归档策略没有任何性能问题。2.4 防刷架构的三个维度防刷这件事单靠一个限流器是不够的。我把它拆成三个维度第一维度是频率限制。同一个用户、同一个 IP、同一个设备指纹在单位时间内的请求次数要有上限。这里用 Redis 的滑动窗口或者令牌桶都行我选的是 Bucket4j 配合 Redis 做分布式限流。第二维度是配额管理。每个用户每天/每月能生成多少张图这是业务层面的限制。配额和频率是两回事频率管的是瞬时压力配额管的是总量成本。第三维度是行为风控。这个最容易被忽略。比如同一个账号在凌晨三点突然高频调用、prompt 内容高度雷同、请求间隔极其规律这些都是机器行为的特征。我加了一个简单的规则引擎命中规则就降级处理或者直接拒绝。这三个维度叠加起来基本能挡住 95% 以上的刷单行为。剩下的靠人工审核和事后追责。3. 核心细节解析与实操要点3.1 接入层参数校验与幂等设计接入层的接口设计我遵循一个原则请求进来先做幂等判断再做限流最后才落任务。幂等这块我要求客户端必须带一个requestId服务端用 Redis 做SETNXkey 是idempotent:{userId}:{requestId}过期时间设 10 分钟。如果 key 已存在直接返回上一次的任务 ID不重复创建任务。这个设计能有效防止用户手抖连点或者网络重试导致的重复生成。参数校验用 Jakarta ValidationSpring Boot 3 已经内置重点校验 prompt 长度、图片尺寸、生成数量这些。prompt 长度我限制在 500 字符以内太长的 prompt 不仅成本高还容易被用来做注入攻击。public record ImageGenRequest( NotBlank Size(max 500) String prompt, NotNull Min(256) Max(2048) Integer width, NotNull Min(256) Max(2048) Integer height, Min(1) Max(4) Integer count, NotBlank String requestId ) {}注意count这个字段一定要限制上限。我见过有人不限制用户一次请求 100 张直接把配额打爆。单次最多 4 张是个比较合理的值。3.2 限流器Bucket4j Redis 的分布式实现单机限流用 Guava RateLimiter 就够了但我们是多实例部署必须用分布式限流。Bucket4j 提供了 Redis 后端的支持配置起来不复杂。核心思路是每个用户一个桶桶的容量和补充速率根据用户等级动态调整。普通用户每分钟 5 次VIP 用户每分钟 20 次。桶的 key 是ratelimit:{userId}。Component public class RateLimitService { private final RedissonClient redissonClient; public boolean tryAcquire(Long userId, int capacity, int refillPerMinute) { RRateLimiter limiter redissonClient.getRateLimiter(ratelimit: userId); limiter.trySetRate(RateType.OVERALL, capacity, Duration.ofMinutes(1), RateIntervalUnit.MINUTES); return limiter.tryAcquire(1); } }这里有个细节trySetRate只在 key 不存在时生效所以不用担心每次调用都重置。但要注意如果用户等级变了需要主动删除 key 让它重新初始化。实操心得限流的粒度不要只按 userId。我加了 IP 维度和设备指纹维度作为补充因为有些刷单是注册大量小号来绕过的。三个维度任意一个超限就拒绝效果比单维度好很多。3.3 任务表设计字段与索引的取舍任务表是整个系统的核心字段设计要兼顾查询效率和存储成本。我的表结构大致是这样字段类型说明idbigint主键雪花 IDuser_idbigint用户 IDrequest_idvarchar(64)幂等 IDpromptvarchar(500)提示词paramsjson尺寸、数量等参数statustinyint状态枚举retry_counttinyint重试次数result_urlsjson结果图片地址error_msgvarchar(500)失败原因cost_creditsint消耗配额created_atdatetime创建时间updated_atdatetime更新时间索引方面我建了三个idx_user_created用户查自己的任务、idx_status_updatedworker 捞任务和超时补偿、idx_request_id幂等查询。这三个索引覆盖了 99% 的查询场景。注意prompt字段不要建索引也不要全文检索。生图 prompt 的查询需求极低建索引纯属浪费。如果真要做内容分析走离线数仓。3.4 异步执行虚拟线程池的正确打开方式Spring Boot 3.2 之后开启虚拟线程非常简单一行配置spring: threads: virtual: enabled: true但这里有个坑开启虚拟线程后Async默认用的还是平台线程池你需要显式配置一个虚拟线程执行器。Configuration public class AsyncConfig { Bean(imageGenExecutor) public AsyncTaskExecutor imageGenExecutor() { return new TaskExecutorAdapter( Executors.newVirtualThreadPerTaskExecutor() ); } }然后在Async(imageGenExecutor)上指定这个执行器。虚拟线程的好处是你可以放心地创建大量并发任务不用担心线程栈内存。但要注意虚拟线程不适合 CPU 密集型任务而生图接口调用是纯 IO 等待正好是它的主场。实操心得虚拟线程配合Semaphore做并发控制是个好组合。虽然虚拟线程很轻但下游生图接口有并发上限用信号量限制同时在跑的任务数避免把下游打挂。4. 完整实操流程与关键环节实现4.1 从请求到任务落库的完整链路用户发起一次生图请求到任务落库中间经历了这些步骤网关鉴权校验 token解析出 userId。幂等检查Redis SETNX命中则返回已有任务。参数校验Jakarta Validation 校验字段合法性。内容审核调用文本审核接口检查 prompt 是否违规。频率限流Bucket4j 三维度检查。配额检查查询用户剩余配额不足则拒绝。任务落库写入任务表状态为 QUEUED。返回任务 ID立即返回不等待生成。整个链路除了内容审核那一步通常 100ms 以内其他都是毫秒级操作。用户拿到任务 ID 后前端轮询查询任务状态。这里我要强调内容审核必须在落库之前做。我见过有系统把审核放在生成之后结果违规图片已经生成出来了才拦截成本已经花掉了。前置审核虽然会误杀一些正常 prompt但成本控制上划算得多。4.2 Worker 捞取任务的两种策略Worker 从任务表捞取待执行任务有两种常见策略策略一轮询拉取。Worker 每隔固定时间比如 1 秒查询一次status QUEUED的任务用LIMIT限制数量配合FOR UPDATE SKIP LOCKED避免多 worker 抢同一批任务。策略二事件驱动。任务落库后发一个事件本地事件或 Redis 消息Worker 监听事件立即执行。我选的是轮询拉取为主事件驱动为辅的混合策略。轮询保证可靠性即使事件丢了也能捞到事件驱动降低延迟新任务秒级开始执行。SELECT * FROM image_task WHERE status QUEUED ORDER BY id ASC LIMIT 20 FOR UPDATE SKIP LOCKED;SKIP LOCKED是 MySQL 8.0 的特性能让多个 worker 并行捞取而不互相阻塞这个特性对任务队列场景简直是量身定做。注意捞取任务后要立即把状态更新为 RUNNING并且记录 worker 标识。这样超时补偿任务才能判断哪些任务卡住了。4.3 调用生图接口的重试与降级生图接口调用失败是常态网络抖动、下游限流、模型排队都会导致失败。我的重试策略是指数退避 最大次数限制。第一次失败后等 2 秒重试第二次等 4 秒第三次等 8 秒最多重试 3 次。超过 3 次标记为 FAILED_FINAL退还用户配额。Retryable( retryFor {ImageGenException.class}, maxAttempts 3, backoff Backoff(delay 2000, multiplier 2) ) public ImageResult callImageApi(ImageGenTask task) { // 调用生图接口 }降级策略方面如果生图接口整体不可用比如连续 10 次调用全部失败触发熔断后续任务直接标记为系统繁忙请稍后重试避免无效调用继续消耗资源。Resilience4j 的 CircuitBreaker 可以很好地实现这个。实操心得重试一定要区分错误类型。网络超时、5xx 错误可以重试4xx 错误比如 prompt 违规、参数错误重试没有意义直接标记失败。我一开始没区分结果违规 prompt 被重试了三次白白浪费了三次调用。4.4 图片存储与 CDN 加速生图接口返回的通常是临时 URL有效期可能只有几十分钟。必须第一时间把图片转存到自己的对象存储否则 URL 过期后用户就看不到图了。我的做法是worker 拿到临时 URL 后立即下载图片流上传到对象存储我用的是兼容 S3 协议的服务然后把永久 URL 回写到任务表。整个过程在 worker 内完成不经过应用服务器磁盘。存储路径我按{userId}/{yyyyMM}/{taskId}.png组织方便按用户和时间归档。CDN 加速这块对象存储一般自带配置好回源即可。注意下载临时 URL 时一定要设置超时时间我设的是 30 秒。有次下游返回的 URL 指向一个响应极慢的地址worker 卡在那里不动导致整个队列积压。加了超时之后就没这个问题了。4.5 配额计量与成本熔断配额计量要和任务状态绑定。任务 SUCCEEDED 时才扣减配额FAILED_FINAL 时退还预扣的配额。我采用的是预扣 结算的模式任务创建时先预扣配额成功则确认扣减失败则退还。成本熔断是最后一道防线。我设置了一个全局的日成本上限当天的累计消耗达到上限的 80% 时告警达到 100% 时自动停止接收新任务只处理已排队的任务。这个机制救过我好几次尤其是在被刷单的时候。public boolean checkBudget() { Long todayCost redisTemplate.opsForValue().get(cost: today); return todayCost null || todayCost DAILY_BUDGET_LIMIT; }5. 常见问题与排查技巧实录5.1 任务卡在 RUNNING 状态怎么办这是最常见的问题。原因通常是 worker 执行过程中崩溃了或者下游接口长时间不返回。我的解决方案是超时补偿任务定时扫描status RUNNING AND updated_at now() - 5min的任务把它们重新置为 QUEUED让其他 worker 重新执行。但这里要注意重新执行前要检查retry_count超过上限的直接标记 FAILED_FINAL。否则一个永远失败的任务会无限循环。5.2 用户反馈生成了但看不到图这个问题排查下来通常是两个原因一是图片转存失败但任务状态被错误地标记为 SUCCEEDED二是 CDN 缓存了旧的 404 响应。第一个问题的修复是转存成功后才更新任务状态转存失败要抛异常触发重试。第二个问题需要在 CDN 配置里对 404 响应设置较短的缓存时间。5.3 限流误伤正常用户限流阈值设得太严会误伤。我的经验是先观察一周的真实流量分布取 P99 作为阈值参考。比如 99% 的用户每分钟请求不超过 3 次那阈值设 5 次就比较安全。同时要给 VIP 用户留出更高的配额避免影响核心业务。5.4 常见问题速查表现象可能原因排查方向解决方案任务一直 QUEUEDworker 挂了检查 worker 日志和心跳重启 worker检查线程池任务卡 RUNNINGworker 崩溃查 updated_at 时间超时补偿任务重新入队图片 404转存失败查对象存储日志转存成功后再更新状态配额不扣减状态流转异常查任务状态机日志修复状态流转逻辑限流误伤阈值过低分析流量分布调整阈值分级限流成本超支刷单或预算失控查用户调用分布加强风控设置熔断5.5 几个我踩过的坑坑一忘记处理下游返回的临时 URL 过期。早期版本直接把临时 URL 存库返回给前端结果用户过半小时再看就 404 了。后来改成必须转存问题解决。坑二重试没有幂等。有次下游接口超时但实际执行成功了我们重试又生成了一张用户拿到两张图。后来在调用下游时带上我们自己的 taskId 作为幂等键下游去重。坑三日志打太多导致磁盘爆满。生图任务的 prompt 和返回结果都很大全量打日志很快就撑爆磁盘。后来改成只打关键字段完整内容存到对象存储日志里只放引用。坑四虚拟线程和 synchronized 一起用导致 pinning。虚拟线程遇到 synchronized 块会被钉在载体线程上失去虚拟线程的优势。后来把关键路径上的 synchronized 换成了 ReentrantLock。6. 后续可以继续扩展的方向这套管道跑了大半年整体很稳。如果后面要继续演进我大概会往这几个方向走一是引入 MQ 替换数据库任务表。等日均任务量突破 50 万数据库轮询会成为瓶颈那时候上 Kafka 做削峰和解耦是顺理成章的。二是做多模型路由。现在只对接了一个生图模型未来可以接入多个模型根据 prompt 类型、成本预算、质量要求动态路由到最合适的模型。三是加一层 prompt 优化。用户输入的 prompt 往往很粗糙可以在调用生图接口前用一个小模型做 prompt 改写和增强提升出图质量。这块我还在实验阶段效果好的话再单独写一篇。四是完善可观测性。现在有基础的指标和告警但链路追踪还不够细。计划接入 OpenTelemetry把从请求到出图的完整链路串起来排查问题会更快。这套东西说到底核心不是某个技术点有多难而是把每个环节的边界想清楚把异常情况都考虑到。生图接口调用本身很简单难的是让它在一个真实的生产环境里稳定、可控、可计量地跑下去。我踩过的这些坑希望你能绕过去。
返回列表