问题描述

项目的业务有个功能是发送短信验证码,为了防止该接口功能被恶意调用,需要做出一些限制,比如每个手机号每60秒只能成功执行一次短信接口请求

问题探索解决

设计方案

最开始没有怎么经过思考便决定使用Redis中间件来简单解决这个问题:

当请求进入时,用手机号作为key检测该key是否存在,如果不存在则将该key存入Redis中,设置60秒过期,并正确进入业务;如果该key存在,则返回错误,不给进入短信业务

1
2
3
4
5
6
String validKey = 手机号 + "xxx_xxx"
if (redisTemplate.hasKey(validKey)){
throw new ValueRuntimeException("xxxxx");
}
redisTemplate.opsForValue().set(validKey,1);
redisTemplate.expire(validKey, 1, TimeUnit.MINUTES);

在我按这个思路做完简单测试之后,我发现我忽略了这是一个并发问题,上面的方法实现不能保证原子性操作,在我实际测试过程中也证明了这一点,20个线程对接口进行并发测试,20个请求全都正确进入短信业务了,这很显然不符合预期要求

既然是并发问题那就要加锁了,问题是怎么设计才能最小代价最有效的实现

我想了几个方案:

  1. 最简单最直接,把那一块短信业务的代码用 synchronized 关键字加锁,一次只能进一个线程执行
  2. 用Redis实现一个简单队列,让请求排队执行,这样就不会有并发问题了
  3. 用 Map<String, ReentrantLock>实现一个内存锁池,针对不同的手机号进行加锁
  4. 使用redisTemplate的 increment() 保证操作的原子性

实验

我在探索的过程中用压测软件来实现500个请求的并发量,侧面评估每个方案的可行性。

第一个方案

使用synchronized关键字对整个代码块加锁,实现很简单,也不会出错,但执行效果很差,只能单机服务可以使用,不同手机号之间还会影响,500个请求的执行平均用了8秒多,直接pass

第二个方案

大概想了一下,没有试,因为引入队列之后会带来一系列复杂的问题,并且还要保证队列的消息可靠性,单个线程去消费执行的也很慢,多线程消费又要保证线程之间数据一致,又要加锁,业务搞的太复杂了,不现实

第三个方案

这个实现起来比较简单,使用java的本地锁ReentrantLock,同样只能单机服务使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private Map<String, ReentrantLock> valueLocks = new ConcurrentHashMap<>();
// 使用 ConcurrentHashMap<>() 来保证多线程下不同手机号锁创建的原子性

try{
ReentrantLock lock = valueLocks.computeIfAbsent(phoneNumber, k -> new ReentrantLock());
//创建锁
if (lock.tryLock(0,TimeUnit.SECONDS)){
//xxx短信业务
}else{
throw new ValueRuntimeException("xxxxx");
}
} catch (InterruptedException e) {
e.printStackTrace();
}

最后统计下来500个线程平均用时0.6秒,比较不错

但有个缺点是需要手动管理60秒过期的锁状态,并且需要使用java虚拟机的内存,可能会影响整体服务

第四个方案

这个最简单,只需要使用redisTemplate的函数,对不同手机号的key进行自增,将原子性操作的加锁,交给Redis解决,如果自增结果大于1则证明发送过了,禁止线程进入

1
2
3
4
5
6
7
Long num = thirdRedisTemplate.opsForValue().increment(validKey);
if (num>1){
throw new ValueRuntimeException("xxxxx");
}
// 执行短信业务xxxxxx

redisTemplate.expire(validKey, 1, TimeUnit.MINUTES);

最后测试结果500个请求并发下用时平均0.6-0.7秒,很优秀

和第三个方案比,优点是Redis自动管理key的过期,比较简单方便,缺点是不能设计太复杂的策略,我这个场景下一个key自增就可以解决,但如果需要太复杂的策略,一个自增数解决不了,就不好用了

问题结论

使用redisTemplate的 increment() 原子性操作来解决短信接口防并发爆破最简单快捷,性能也不错