背景
在我们的一个 Web 课堂系统中,开启课堂会调用接口发送邀请链接公告,后台要限制一个课堂只能发送一次。一旦重复发送,用户会看到多条重复公告,直接影响课堂体验。

现象
同一个课堂出现了多条公告记录

解析
直接看代码,使用了分布式锁进行加锁校验,锁等待时间为0,也就是获取不到锁立刻返回。锁超时时间为课堂的超时时间,为12小时。

结论先行
根因并不是并发冲突,而是锁本身可重入:两个请求虽然间隔较长,但恰好复用了同一个应用线程,导致同线程二次获取锁成功,最终重复发送公告。
1、猜想一:发送公告有重试逻辑,有可能是重试逻辑有问题导致的。
在Google的接口调用中,使用动态代理进行接口的统一处理,如果调用Google接口返回了401状态码,就需要刷新AccessToken然后重新调用,如果刷新AccessToken失败就需要用户重新授权。

初步看代码逻辑没有问题,我们可以查看应用日志,如果是重试的问题导致的,那么这两次发送公告都应该是同一个请求,也就会是同一个TraceId,可以看到这两次发送成功是属于两个不同的请求,TraceId也是不一样的,那么可以排除这个猜想。


2、猜想二:锁没有生效,有并发问题。
我们使用 Jmeter 对这个接口进行压力测试,开启100个线程,结果是只有一个线程返回成功,其他线程都返回10304业务状态码,表示已经发送过公告了,而且始终没办法复现,返回成功状态码的请求始终只有一个。

3、猜想三:第一次获取锁成功了,后面删除了锁,所以第二次获取锁又成功了
查看Redis数据库也能看到锁对应的数据,TTL剩余9379秒,通过时间计算,可以得出这个Key的设置时间为9:49,跟第一次请求的时间是一致的,所以第二次获取锁的时候,这个锁是存在的。

4、猜想四:从以上的现象可以看出,这不是并发问题导致的,而且是属于不同的请求,两个请求基本没有什么关联,间隔也有8分钟左右。第二次获取锁的时候,锁的key也确实存在,确实是重复获取到了锁,那么基本确定只有一种可能,这是可重入锁,如果两个请求是同一个线程,那么就能重复获取到锁。查看应用日志,可以发现这两个请求确实属于同一个线程。
验证猜想:将Tomcat的工作线程数设置为1,这样每次请求都会是同一个请求,结果是每次请求都发送成功。
那么压力测试为什么复现不了这个问题呢,原因是压力测试时,100个请求基本同一时间发出,而Tomcat默认的最大线程池数量为200个线程,所以这100个请求都属于不同的线程。


解决方案
改为不可重入的原子加锁方式,使用 SET key value NX EX(或 Lua 脚本)一次性完成“加锁 + 过期时间设置”。同时补充了并发回归测试,重点覆盖同线程复用和高并发压测场景,避免问题再次出现。
附
分布式锁加锁流程图
