《从零开发校园墙微信小程序》进阶篇之五。上一篇说登录是异步的。这篇讲异步背后的任务队列 :怎么保证同一个用户狂点只产生一个任务、怎么串行并限速地去访问教务、怎么判断一次失败该不该重试。
为什么需要队列
设想开学第一周,大量同学在同一时间点「绑定课表」。如果每个请求都立刻、并发地 去访问教务系统:
- 教务系统会被突发流量压垮,我们自己也可能被它限制;
- 用户之间互相挤占,每个人都很慢;
- 后端线程被大量慢请求占住。
所以引入一个队列 ,把「受理请求」和「真正去访问教务」解耦:
用户点击 → 入队(立即返回 taskId) …… 后台消费者每隔一小段时间取一个任务,串行处理这里选用 Redis 的 List 做队列,原因是:项目里本来就有 Redis;任务量不大;rightPush 入队 / leftPop 出队,天然 FIFO。
提交:防重复点击
用户的手指是不可靠的。焦急的用户会连点十几次「绑定」。如果每次点击都产生一个任务,轻则浪费,重则触发教务的风控(短时间内同一账号多次登录)。
解决办法是一把以 openid 为 key 的活跃锁 ,用 Redis 的 SETNX:
private static final String ACTIVE_PREFIX = "school_login_active:";
private static final long ACTIVE_TTL_SECONDS = 10 * 60; // 活跃锁超时,防异常残留死锁
public String submit(String type, String openid, String username, String password,
String randomCode, String jsessionid) {
String taskId = UUID.randomUUID().toString();
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent(ACTIVE_PREFIX + openid, taskId, Duration.ofSeconds(ACTIVE_TTL_SECONDS));
if (!Boolean.TRUE.equals(locked)) {
log.info("教务登录重复提交被拦截 openid={}", openid);
return null; // 控制器据此返回 409
}
...
}控制器看到 null 就返回 409 已有登录任务处理中,请稍候。于是狂点十次,只会产生一个任务 。
两个细节非常关键:
1. 锁必须有 TTL。 如果进程在任务执行中崩了,没有 TTL 的锁永远不会释放,这个用户就永远被锁住。10 分钟的超时是一个兜底。
2. 任务创建失败时必须释放锁。
try {
// 写 Redis 任务数据、入队、写 DB ...
} catch (Exception e) {
// 任务创建失败必须释放活跃锁,否则该 openid 被锁 10 分钟,前端反复提交只会一直失败
try { stringRedisTemplate.delete(ACTIVE_PREFIX + openid); } catch (Exception ignore) { }
throw e;
}这是加锁代码最常见的疏漏:拿到锁之后,任何失败路径都要有释放的出口。 否则用户会陷入「提交失败 → 重试 → 提示处理中 → 一直卡着」的死循环。锁的正常释放发生在任务完成(成功或失败)时。
任务的状态存两份
// Redis:任务数据(用于排队),TTL 较长,便于排查
// DB:school_login_task 表(状态的权威来源)- Redis 里的任务 hash 用于队列和快速读取;
- DB 里的
school_login_task才是状态的权威来源,前端轮询/class/loginTaskStatus时查的是 DB。
为什么要在 DB 里也存一份?Redis 重启或被清理后,任务状态不能丢;管理后台还需要「卡住的用户」这种统计和筛选,用 SQL 做最方便。
任务状态流转很简单:pending → processing → done / failed,每次变更同时更新 DB 和 Redis,done 时释放 openid 活跃锁。
消费者:每 2.5 秒取一个,串行 + 随机延迟
@Scheduled(fixedDelay = 2500)
public void consume() {
String taskId = stringRedisTemplate.opsForList().leftPop(QUEUE_KEY);
if (taskId == null) return;
...
markStatus(taskId, "processing", null, null, null);
// 随机延迟,错峰访问教务(参数管理端可调)
long delayMinMs = dynamicConfigService.getInt("school.login.delay-min-ms");
long delayMaxMs = dynamicConfigService.getInt("school.login.delay-max-ms");
long delay = delayMinMs + RandomUtil.randomLong(Math.max(0, delayMaxMs - delayMinMs));
if (delay > 0) Thread.sleep(delay);
Result result = doLogin(type, openid, username, password, randomCode, jsessionid);
...
}几个要点:
fixedDelay,不是fixedRate:fixedDelay是「上一次执行完 之后再等 2.5 秒」,所以任务就算处理得慢,也不会堆叠并发;- 一次只处理一个任务 :天然串行,对教务系统是友好的;
- 随机延迟 :在最小和最大延迟之间取一个随机值,让访问错峰 ,避免出现规律性的整齐请求。这两个参数放在管理端动态配置 里,教务系统吃紧时运营可以直接调大,不用发版;
- 单个任务失败要
catch住,只标记这个任务失败,不能让异常把消费者线程带崩。
注意:消费者和第 2 篇、第 9 篇提到的其他定时任务共用调度线程池 ,所以才需要把线程池从默认 1 个调到 4 个(微博转发一旦慢,就不会把登录队列一起拖停)。
前端怎么配合:轮询 + 静默重试
小程序这一侧,提交之后的流程是(真实代码):
// 轮询登录任务结果:每 2s 一次,最多 60s;done 返回 {code,msg},failed 抛错
const pollTask = async (taskId: string) => {
for (let i = 0; i < 30; i++) {
await new Promise(resolve => setTimeout(resolve, 2000));
let st: any;
try { st = await loginTaskStatus(taskId); }
catch (e) { continue; } // 单次查询失败继续等,不当成任务失败
const stData = st?.data?.data;
if (!stData) continue;
if (stData.status === 'done') return { code: Number(stData.resultCode || 0), msg: stData.resultMsg || '' };
if (stData.status === 'failed') throw new Error(stData.error || '登录处理失败');
}
throw new Error('登录处理超时,请稍后重试');
};三个值得注意的地方:
- 轮询有上限 (每 2 秒一次、最多 30 次,即 60 秒),超时就明确报错,不会让用户无限等下去;
- 单次查询失败不等于任务失败 ——可能只是用户手机网络抖了一下,
continue继续轮询即可; done和failed是两种不同的终态:done表示任务处理完了(但业务结果可能是成功或失败,看resultCode),failed表示任务本身执行出错。前端要分别处理。
在这之上,前端对验证码类的偶发失败 还有一层静默重试:
// 后端 auto 任务内部已内置 4 轮"换验证码+换代理"重试;若仍返回验证码错误(OCR 偶发失灵),
// 前端静默重试几次即可,失败就直接提示,不再弹出验证码框。
const MAX_AUTO_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_AUTO_ATTEMPTS; attempt++) {
const response = await loginToStudy(username, password, '' /* 空验证码 → 后端 auto 模式 */);
...
if (response?.data?.code === 409) { triggerNotify('warning', '已有登录任务处理中,请稍候'); return; }
const taskResult = await pollTask(taskId);
if (taskResult.code === 200) { ...成功...; return; }
// 验证码类失败(401 / 403 带"验证码/随机码")才重试,其余失败直接提示
}于是形成了两层重试 :后端一个任务内部最多 4 轮,前端最多再提交 3 次任务,叠加起来成功率进一步提高。注意前端只对「验证码类失败」重试,密码错误这类确定性失败不会重试 ,和后端的白名单思路一致。成功之后,前端还会立即清空输入框里的学号和密码 ,不在界面状态里残留凭据。
多轮重试:4 轮
教务系统的单次登录成功率并不高(集群会话不稳定、验证码识别有误差),所以不能只试一次:
/**
* 登录尝试轮数:单次成功率约 25%~50%(教务集群会话不稳定),4 轮可到 68%~94%。
*/
private static final int MAX_LOGIN_ROUNDS = 4;
for (int round = 1; round <= MAX_LOGIN_ROUNDS; round++) {
String pinned = useProxyPool.pickOne();
if (pinned != null) useProxyPool.pin(pinned); // 一轮内固定出口(见第 12 篇)
try {
Result r = tryLoginOnce(forUpdate, openid, username, password, null, null);
if (!isRetryableLoginFailure(r)) return r; // 成功,或者是确定性失败,直接返回
last = r;
} finally {
useProxyPool.unpin();
}
}
return new Result<>(last.getCode(), last.getMessage() + "(已自动换码重试 " + MAX_LOGIN_ROUNDS + " 轮)", "");注意最后一行:把「已自动重试了 4 轮」写进错误信息 。这样排查问题时一眼就能区分「一次就失败」和「重试了 4 次仍失败」,运营回复用户时也有据可依。
数学上:单次成功率 p = 0.25 ~ 0.5,4 轮至少成功一次的概率是 1 - (1-p)^4,即 68% ~ 94%——这正是代码注释里那组数字的来源。
最重要的一个函数:什么失败值得重试
这是整个任务系统最精华的部分。看这段代码:
/**
* 瞬态失败判定。
* 为什么抽出来共享:管理端「卡住用户」列表要靠它判断"这个人的失败值不值得重投"。
* 如果列表和重试各写一套判定,迟早会漂移——实际就踩过:列表最初只排除了
* "用户名或密码错误",结果漏掉了 403 的另一种确定性失败 "该帐号已锁定",
* 于是自动重投会每 10 分钟去撞一次已锁定的账号,越撞越锁。
*
* 所以这里用【白名单】(只认明确属于瞬态的情况),而不是排除已知的确定性失败——
* 教务以后冒出新错误文案时,白名单会保守地"不重投",而不是误判成可重投。
*/
public static boolean isTransientFailure(Integer code, String msg) {
if (code == null) return false;
// 401:OCR 验证码识别失败,换码重试;500:教务瞬时异常
if (code == 401 || code == 500) return true;
if (code != 403 || msg == null) return false;
return msg.contains("验证码") || msg.contains("随机码")
|| msg.contains("登录系统") || msg.contains("学期信息");
}这段注释里藏着两条非常实用的工程原则:
原则一:判定逻辑只写一份
「是否值得重试」这个判断,同时被两处用到:自动重试 ,和管理端的「卡住用户」列表 (判断哪些人的失败值得人工 / 自动重投)。如果两边各写一套,迟早漂移 。真实发生过:列表里漏掉了「该帐号已锁定」这种确定性失败,结果自动重投每 10 分钟去撞一次已锁定的账号,越撞越锁 。
所以抽成一个公共的静态方法 ,两处共用。
原则二:用白名单,而不是黑名单
这是更深刻的一条。判断「哪些失败可以重试」有两种写法:
| 写法 | 含义 | 遇到从未见过的 新错误 |
|---|---|---|
| 黑名单 | 排除已知的确定性失败,其余都重试 | 会重试 (危险) |
| 白名单 | 只重试明确属于瞬态的情况,其余都不重试 | 不会重试 (安全) |
教务系统以后一定会冒出新的错误文案。黑名单会把它们当成「可重试」,可能就在不该重试的地方(比如账号被锁)反复冲击;白名单则保守地选择不重试 ,最坏的结果只是「某个瞬态失败没自动重试」,让用户手动再点一次即可。当重试的代价比漏重试的代价高得多时,一定用白名单。
失败原因要「说人话」
用户看到的失败提示,不应该是系统内部的原始错误。项目里有一个翻译函数:
private static String friendlyFailure(Integer code, String msg) {
String m = msg == null ? "" : msg.replaceFirst("^Fails:", "").trim();
if (code == 403 && m.contains("用户名或密码错误")) {
return "用户名或密码错误:<给出如何确认/找回密码的指引>";
}
if (code == 401 && (m.isEmpty() || m.contains("验证码"))) {
return "验证码识别失败(已自动换码重试;仍失败可在小程序手动输入验证码)";
}
if (code == 500 && m.isEmpty()) {
return "教务系统瞬时异常(已自动换代理重试)";
}
return m;
}这个函数做了两件事:
- 对高频失败给出可执行的指引 。「用户名或密码错误」是最高频的失败,而新生常常不清楚自己的初始密码是什么。在提示里直接告诉他们去哪里确认或找回,能省掉大量客服咨询(具体规则取决于学校,这里不展开;也不要把任何账号的初始密码规则公开写在博客或文档里 );
- 区分「用户输入问题」和「系统故障」 。密码错是用户的事,不应该在日志、任务列表里被误判成系统故障,让运维白白排查。
更新课表:先解析成功,再删旧数据
用户可以随时点「更新课表」。这里有一个数据安全 的设计:
if (forUpdate) {
List<Schedules> courseData = CourseScheduleParser.parse(course);
if (!courseData.isEmpty()) {
// 补齐学号、学期(解析器不填,必须手动补齐)
for (Schedules s : courseData) { s.setStudentid(username); s.setSemester(semester); }
schedulesService.deleteMySchedulesBySemester(info); // 先删旧的
schedulesService.saveOrUpdateBatch(courseData); // 再插新的
} else {
// 解析为空:保留旧课表,仅提示
log.warn("更新课表解析为空,保留旧课表 username={}, semester={}", username, semester);
}
return new Result(200, "Success", "登录成功");
}只有新课表解析出了内容,才会删除旧课表。 如果教务页面改版导致解析器一门课都没解析出来,旧课表原样保留 ,用户至少还能看到上学期成功绑定的数据,而不是登录显示成功、课表却被清空。
这里还有一条历史教训(写在注释里):解析器不填学号和学期 ,必须由调用方补齐;曾经有一次漏了补,插入了一堆 studentId = NULL 的孤儿行,造成「Redis 里有课表,DB 查不到」。解析器只做解析,上下文字段由调用方负责,两边的职责要写清楚。
「更新课表」的免输入体验
用户更新课表时,通常不想再输一遍密码。项目的做法是:未传账号密码时,复用该用户最近一次成功登录 的学号和密码——密码是从 DB 的加密字段解密出来的:
// 更新课表:未传账号密码时复用该 openid 最近一次成功登录的学号/密码(DB 加密存储,免重复输入)
if (userName == null || userName.isEmpty() || passWord == null || passWord.isEmpty()) {
String[] creds = schoolLoginQueue.lastCredentials(loginId);
if (creds == null) {
return Mono.just(new Result(403, "未找到可复用的登录信息,请先重新绑定课表", ""));
}
...
}这就是前面强调密码必须 AES 加密入库 的原因:它不仅要存,还要在合适的时机被还原使用。需要还原的凭据,必须用可逆加密,并且密钥要妥善保管。
小结
- 队列解耦 「受理」和「执行」,Redis List 够用;
- 防重复提交:
SETNX活跃锁,必须有 TTL ,失败路径必须释放 ; - 状态双写:Redis 做队列和缓存,DB 是权威 ;
- 消费者:
fixedDelay、串行、随机延迟 、延迟参数做成动态配置; - 多轮重试,并把重试了几轮 写进失败信息;
- 「是否重试」的判定只写一份,并且用白名单 ;
- 失败信息要翻译成可执行的指引 ,区分用户错误与系统故障;
- 更新数据:新数据确认可用,才删除旧数据 ;
- 需要还原的凭据用可逆加密,不用时及时清理。
最后一篇,我们看拿回来的 HTML 是怎么变成结构化课表的。