登录接口本来好好的,可一到网络不稳的时候就开始抽风:明明验证码是对的,用户却收到"验证码错误"或"系统繁忙";刷新一下又能用,再刷新又不行。查日志发现,报错的位置根本不是验证码校验逻辑,而是它"够不到"存验证码的地方。
现象
验证码接口在高峰期或网络抖动时频繁报超时,随后整条登录链路失败。日志里能看到类似:
RedisConnectionFailureException: Unable to connect to Redis
... connect timed out
用户侧的表现是:输对了验证码,点登录却没有反应,或提示"验证码已过期/错误"。开发者第一反应往往是"验证码逻辑是不是写错了""是不是并发问题",但真正的问题在验证码存在了哪里。
根因
根因是架构耦合:业务把验证码这种短生命周期、单机使用、丢失也无所谓的状态,放进了远程缓存(比如 Redis)。
验证码的读写路径被设计成了这样:
- 用户请求验证码 → 服务端生成一个 6 位随机数 →
SET captcha:<手机号> <code> EX 300写进远程缓存; - 用户提交登录 → 服务端
GET captcha:<手机号>读出来比对。
这条链路上,远程缓存成了一个单点:只要应用与缓存之间的网络抖一下、缓存的连接超时一下,GET 拿不到值,校验就直接判失败。于是"缓存不可用"被放大成了"登录不可用"。
更深一层的问题是:验证码根本不需要被共享。 它只在一次"请求验证码—提交登录"的交互中被同一个用户读一次,是天然的本地、短命状态。把它放到远程缓存里,你付出了网络往返的代价,换来的是一个并不需要的"跨实例共享"能力,同时引入了一个不必要的故障域。
判断一份状态该放哪,关键问一句:它真的需要被多个实例共享吗?
- 需要跨实例共享、且丢失代价高(比如用户会话、分布式锁)→ 放远程缓存,并接受它的网络成本。
- 只在单次交互内有效、丢了重来即可(验证码、临时 token、限流计数)→ 优先放本地内存。
解决
在开发/单机阶段,把验证码从远程缓存改到本地内存,直接去掉这一环的远程依赖。用一个带过期清理的线程安全容器即可:
// 简易本地验证码存储:key -> (code, 过期时间)
private final ConcurrentHashMap<String, Entry> store = new ConcurrentHashMap<>();
record Entry(String code, long expireAt) {}
public void put(String phone, String code, long ttlMillis) {
store.put(phone, new Entry(code, System.currentTimeMillis() + ttlMillis));
}
public boolean verify(String phone, String code) {
Entry e = store.get(phone);
if (e == null) return false;
if (System.currentTimeMillis() > e.expireAt()) {
store.remove(phone);
return false;
}
return code.equals(e.code);
}
配套两点:
- 过期清理。
ConcurrentHashMap不会自动过期,要么在verify时顺手清过期项,要么用一个定时任务周期扫描,避免内存无限增长。 - 确认部署形态。单机部署时本地内存完全够用;如果确实多实例部署,需要用户被路由到同一实例(会话粘滞),或者退一步——即使校验失败也返回友好提示、允许重发,而不是直接判定登录失败。
改造后,登录链路少了"网络能不能通缓存"这个前提,抖动的传导路径被切断。
延伸与预防
这条坑的普适教训是:上线远程依赖之前,先问"这一环真的需要共享吗"。
不是所有状态都值得放远程缓存。把本地就能解决的事推给一个跨网络的服务,等于给每一次请求都绑上一个新的失败点。反过来,也要避免走极端——如果状态确实需要多实例一致(比如"同一账号只能在一处登录"),那就该放远程,并接受它的可用性成本,同时给它加降级策略(缓存不可用时走什么兜底路径)。
排查思路上还有一条:当"某个功能失败"的报错指向依赖服务时,先分清那部分是"业务必需"还是"架构强加"。 很多故障不是逻辑写错了,而是把不该耦合的东西耦合在了一起。