主题
定时任务与验证码预生成
captcha-schedule 用 Spring
@Scheduled的 5 线程共享调度池跑 11 个周期任务;核心链路是「sys_conf 缺口 → ProduceWorker → 三档图 + S3 → image_output / image_conf → Redis 入队」,而 ES 的写入与清理都不在预生成侧。
一、调度模型:是 Spring @Scheduled,不是 Quartz
AGENTS.md 里把 captcha-schedule 描述成「Quartz 定时任务」,pom.xml 里也确实引了 Quartz 2.3.2,但代码零使用:没有 JobDetail、没有 Trigger、没有 SchedulerFactoryBean。全部周期逻辑由 <task:annotation-driven/> + @Scheduled 驱动,Main.java 只是 ClassPathXmlApplicationContext 拉起 Spring 上下文后常驻。
由此带来三个必须先记住的事实:
- 共享调度池
pool-size=5。11 个任务抢 5 个线程,任何一个任务单次执行时间超过它的周期,就会挤占其它任务的槽位。预生成任务在图片量大的时候正是「一次跑几十秒」的类型,这是线上最容易被忽视的耦合点。 fixedDelay与cron混用。fixedDelay从上次执行结束开始计时,因此实际间隔 = 执行耗时 + 配置间隔;cron则是墙钟对齐。看日志判断「任务是不是漏跑了」时必须先分清两者。- 无分布式锁。调度层没有任何跨实例互斥设施,多副本会并发执行同一任务(见第六节)。
二、任务总览
| 任务类 | 触发方式 | 周期量级 | 关键动作 | 幂等性 | 多副本风险 |
|---|---|---|---|---|---|
PuzzleProduceWorker | @Scheduled | 秒级 fixedDelay | 拼图类型缺口补产 | 无锁,计数器竞争 | 高:重复生成 |
VoiceProduceWorker | @Scheduled | 秒级 fixedDelay | 语音类型缺口补产 | 无锁 | 高:重复生成 |
CommonProduceWorker | @Scheduled | 秒级 fixedDelay | 点选/图标/语序等通用类型补产 | 无锁 | 高:重复生成 |
SpaceProduceWorker | @Scheduled | 秒级 fixedDelay | 空间推理类型补产 | 无锁 | 高:重复生成 |
ImageCleanUpWorker | @Scheduled | cron 每日低峰 | 清理过期图片记录与存储对象 | 无锁 | 中:重复删,报错噪音 |
ImageDeleteSync | @Scheduled | cron | 同步 DB 删除状态到存储侧 | 无锁 | 中:重复同步 |
ImageStatusSyncJob | @Scheduled | cron | 图片状态/版本状态回写 | 无锁 | 中:状态抖动 |
EsIndexCleanUpWorker | @Scheduled | cron 每日 | 删除超期索引 | 与 ILM 双机制兜底 | 低:删不存在的索引 |
SubVersionSwitchJob | @Scheduled | fixedDelay=60s | 子版本切换判定 | 有缺陷(见第七节) | 高:反复切换 |
FeatureExpireWorker | 已停用(@Scheduled 被注释) | — | LSH 特征过期清理 | 死代码 | 无 |
CaptchaRequestStatWork | @Scheduled | cron | 请求量统计落库 | 真幂等(游标 + 唯一索引) | 低 |
周期量级以各类上的注解为准,仓库内没有把 cron 表达式外置到 properties,调周期必须改代码重新打包。这是运维上最不灵活的一环。
三、验证码预生成全链路时序
sys_conf 表
key: captcha.produce.count ← 每类型/每图片组的目标库存
│ SysConfLoader 每 30s 轮询刷新(captcha-base/sys 包)
▼
AbstractProduceWorker.run() ← 4 个 ProduceWorker 共同父类
│ getPerGroupGap()
│ gap = 目标值 - Redis 中各图片组队列剩余量
│ 按组遍历(当前实现为 16 个图片组队列)
▼
Puzzle / Voice / Common / Space ProduceWorker
│ 按 CaptchaType 分派到具体 CreateService,提交 FillTask
▼
ThreadPoolExecutor
│ core=max=20,queue=2048,RejectedExecutionHandler=CallerRunsPolicy
▼
*CreateService.create()
│ 拼图:切块 + 换块 + 滤镜扰动(纯 Java,无 OpenCV)
│ 语音:逐位拼接 mp3 + noise
▼
storeImageOutput() ← 输出三档图(档位由输出类型字段区分)
│
├─► S3UploadService → S3 兼容对象存储(当前唯一生效的上传实现)
▼
image_output 表(图片明细) + image_conf 表(组状态 / 计数 / 版本)
│
▼
addToRedis() ← 按图片组写入 Redis 队列
│
▼
实时侧 captcha-server 直接 POP 队列出图链路的几个要点:
getPerGroupGap()是所有预生成任务的共同入口,它决定了「补多少」。目标值单点来自sys_conf的captcha.produce.count,30s 才刷新一次,所以运营在后台调大库存时,最坏要等 30s 才开始补。- 线程池 20 线程 / 队列 2048 / CallerRunsPolicy。
CallerRunsPolicy意味着队列打满时由提交线程(即调度池那 5 个线程之一)自己跑,调度池会被拖住——这是「任务看起来停了」的典型成因。 image_conf与image_output是两套语义:前者是「组」的状态与计数,后者是「张」的明细。排查库存不一致时先看 Redis 队列长度,再看image_conf计数,最后才下钻image_output。
四、拼图产线:切块 + 换块 + 滤镜扰动
拼图(CaptchaType.DRAG,ImagePuzzleProducer 相关实现)的核心算法只有四步:
- 底图按预设形状切块,得到拼图块与镂空背景;
- 交换两块,破坏「缺口位置 = 拖拽终点」的直觉映射;
- 用
ExposureFilter/RGBAdjustFilter做亮度与色偏扰动,抑制基于像素匹配的模板攻击; - 用 Thumbnails(thumbnailator)统一输出三档尺寸。
关键结论:生产代码零 OpenCV。JavaCV / OpenCV 4.9.0 的依赖只在 captcha-test 模块的破解用例里出现(用来自动识别缺口位置、评估破解难度)。因此「去掉 OpenCV 依赖」在服务端是可行评估方向,但测试模块会一起废掉,不能简单删依赖。
五、语音产线:逐位拼 mp3
语音验证码不是 TTS 实时合成,而是素材拼接:
- 素材约定为
{lang}-{digit}.mp3,lang取自LanguageSetEnums的语言编码(zh/en/fr/ru/ja/pt/es/ko/id/ar/de); - 按答案位数逐位取素材拼接,再叠加 noise 音轨做抗识别处理;
- 语言集合的
setId全部为负数(-1中文 …-11德语),与image_conf/image_set的正数 id 空间天然隔离,避免主键冲突。
新增语言时链路是:LanguageSetEnums 加枚举 → 补素材文件 → 建对应负 id 的图片组配置 → 重启捕产。缺素材不会报错,只会静默产出无声或错位的语音,属于隐蔽故障。
六、ES 不在预生成侧
这是最容易写错的架构认知:
- 预生成不写 ES。
captcha-schedule全程不碰captcha_info_*索引。 - ES 写入在实时侧:
captcha-server的CaptchaInfoServiceImpl.java:112-136在验证完成后异步落库 + 写索引。 - ES 清理是双机制:
EsIndexCleanUpWorker(应用内定时删超期索引)+build/es/下的 ILM 策略(ES 侧自动滚动删除)。两套都开时,应用侧的删除通常是冗余兜底,ILM 才是主路径;如果客户环境 ES 版本不支持 ILM,应用侧任务就是唯一手段。
由此推出一个运维结论:预生成正常但「数据查不到」时,不要去看 schedule 模块,问题在 server 的写入分支或 ES 索引/别名状态。
七、幂等性与单副本硬约束
captcha-schedule 必须单副本 + strategy: Recreate,原因不是「性能」,而是代码里根本没有并发保护:
- 4 个
ProduceWorker的run()到addToRedis()之间没有任何锁:两个副本同时算 gap、同时补产,结果是双倍图片、双倍 S3 对象、image_conf计数虚高,Redis 队列里出现重复素材。 ImageCleanUpWorker、SubVersionSwitchJob同样无锁。- 全模块只有两处
AtomicBoolean,且都是进程内防重入(防止同一副本上一轮没跑完下一轮又进来),对跨副本完全无效。 - 唯一真幂等的是
CaptchaRequestStatWork:它靠sys_conf里的游标记录上次统计到的时间点,并且落库表上有唯一索引兜底,重复执行最多是空转。
SubVersionSwitchJob 的当天反复切换缺陷
needUpdate() 的判据只有「今天是不是 1 号 / 今天是不是周一」,没有「今天是否已经切过」的记忆位,而任务本身是 fixedDelay=60s。于是在每月 1 号或每周一,它会在当天每 60 秒判定一次「需要切换」并真的执行切换,直到日期翻篇。
影响面:
- 版本/子版本状态字段高频抖动,实时侧取图可能命中「切换中」的中间态;
ImageStatusSyncJob与它叠加时,状态回写互相覆盖;- 60s 一次的切换动作在日志里形成大量噪音,掩盖真异常。
缓解手段只能是「当天手动停任务」或「改判据加去重」。这是个应当排期修复的已知缺陷,不是配置问题。
八、配置项与死代码
captcha-schedule/src/main/resources/produce.properties 内容极少,只有:
properties
# 点选类验证码的生产线程数
click.produce.thread.count=20除此之外,所有预生成参数都走两处:
| 参数来源 | 内容 | 生效方式 |
|---|---|---|
sys_conf 表 | captcha.produce.count 等目标库存 | SysConfLoader 30s 轮询,改库即生效 |
produce.properties | 生产线程数等固定参数 | classpath 读取,改文件需重启 |
需要强调:captcha-schedule 是唯一天然支持外部配置覆盖的模块——captcha-schedule/docker/entrypoint.sh:61 会把 /java-app-dir/approot/conf/*.properties 放到 classpath 最前,所以 produce.properties 可以挂载覆盖而不必重新打包。相对地,WAR 模块只能靠 ConfigMap + subPath 遮蔽 WEB-INF/classes 下的同名文件。
LSH 是死代码
captcha-schedule/.../lsh/ 整包(含 FeatureExpireWorker)是局部敏感哈希相似图检索的遗留实现:
FeatureExpireWorker标了@Deprecated,且类上的@Scheduled注解已被注释掉,任务根本不会被注册;captcha-base侧的feature/LshFeature*(LshFeatureMapper/LshFeatureService)仍存在,被少量历史代码引用,但没有新的写入源;- 结论:LSH 链路既不产生也不清理,
lsh_feature类表只会单调增长。评估表体量时必须按「历史遗留表」处理,不能假设有 TTL。
九、运维建议清单
| 场景 | 先看 | 再看 |
|---|---|---|
| 出图变慢 / 取不到图 | Redis 各图片组队列长度 | image_conf 计数 vs sys_conf 目标值 |
| 图片量暴增 | 是否多副本部署 | ProduceWorker 日志中的 gap 计算 |
| 任务「整体卡住」 | 调度池是否被 CallerRunsPolicy 拖死 | 单任务执行耗时是否超过周期 |
| 1 号 / 周一状态抖动 | SubVersionSwitchJob 日志频率 | 版本字段当前值 |
| 数据查不到(ES) | CaptchaInfoServiceImpl 写入分支 | 索引是否存在、ILM 是否误删 |
| S3 对象堆积 | ImageCleanUpWorker 是否在跑 | 删除同步任务与存储侧生命周期规则 |
一句话收口:captcha-schedule 的一致性完全依赖「单副本 + Recreate」这个部署约束,任何把它当无状态服务水平扩容的做法,都会立刻在库存和存储上体现为重复数据。