业务描述
最近在项目中遇到个问题,短信发送的并发请求漏洞:业务需求是需要限制一个号码一分钟内只能获取一次随机码,之前的实现是短信发送请求过来后,先去数据库查询发送记录,根据上一次的短信发送时间和当前时间比较,如果时间差小于一分钟,则提示短信获取频繁,如果超过一分钟,则发送短信,并记录短信发送日志。
问题分析
短信发送是一个很敏感的业务,上面的实现存在一个并发请求的问题,当同一时间有很多请求过来时,同时去查库,同时获取到上一次发送时间没有,或者已超过一分钟,这时候就会重复发送短信了。
使用Redis解决问题
Redis incr 可以实现原子性的递增,可应用于高并发的秒杀活动、分布式序列号生成等场景。这里我使用它来计数实现一分钟内只接受一次请求。
我们在后台接到短信发送请求后,使用Redis的incr设置一个递增KEY(KEY由固定字符串+手机号码组成),并判断该KEY的数值,如果等于1,说明是第一个请求,我们将该KEY值有效期设置为一分钟;如果该KEY的数值大于1,说明是1分钟内的多次请求,这时我们直接返回短信获取频繁,代码如下:
String redisKey = "SMS_LIMIT_" + smsPhone;
long count = redisTemplate.opsForValue().increment(redisKey, 1);
if (count == 1) {
//设置有效期一分钟
redisTemplate.expire(redisKey, 60, TimeUnit.SECONDS);
}
if (count > 1) {
resultMap.put("retCode", "-1"); resultMap.put("retMsg", "每分钟只能发送一次短信");
outPrintJson(resultMap);
return;
}
/** 发送短信 */
......
/** 记录发送日志 */
......
使用java锁机制解决问题
我们知道Java有一个锁机制,使用synchronized关键字可以将对象或代码块锁定,当有很多请求过来时,第一个获得锁,后面的请求需要等第一个处理完后才能继续,就好像排队上厕所。代码如下:
1、我们创建一个发短信的工具类,使用单例模式(单例模式的兼容效率和并发的写法,参照我前面的博客【善用设计模式-单例模式】)
private static byte[] LOCK = new byte[0];
private static SMSendUtil smSendUtil;
private SMSendUtil(){};
/**
* @Description:单例 (防并发)
* @param:@return
* @return:SMSendUtil
* @throws:
* @author:pengl
* @Date:2016年8月17日 下午4:16:08
*/
public static SMSendUtil getInstance(){
if(smSendUtil != null)
return smSendUtil;
synchronized (LOCK) {
if(smSendUtil == null)
smSendUtil = new SMSendUtil();
}
return smSendUtil;
}
2、编写发短信的方法,并在方法上加synchronized关键字
/**
* 发送登陆验证短信(使用全局锁synchronized避免并发请求)
*/
public synchronized Map sendSms(String smsPhone) {
/** 查询最近一次发送时间 **/
......
/** 一分钟时间限制判断 **/
......
/** 发送短信 **/
......
/** 记录发送日志 */
......
}
java锁机制优化
在单例模式下,通过在方法上加synchronized关键字,是可以解决并发问题的,但是这种全局锁,性能非常差,如果有100个用户同时获取短信随机码,这100个用户只能排队一个一个发送短信。我们可以使用更细小颗粒的锁来控制并发问题,这里使用手机号码做为颗粒纬度,优化代码如下:
1、根据手机号码获取相应的锁对象
private static ConcurrentHashMap lockerStore = new ConcurrentHashMap();
private static Object getPhoneNumberLock(long phone) {
lockerStore.putIfAbsent(phone, new Byte[]{});
Byte[] ret = lockerStore.get(phone);
return ret;
}
/**
* 发送登陆验证短信(使用号码颗粒级锁synchronized避免并发请求)
*/
public Map sendSms(String smsPhone,boolean flag) {
synchronized (getPhoneNumberLock(Long.parseLong(smsPhone))) {
/** 查询最近一次发送时间 **/
......
/** 一分钟时间限制判断 **/
......
/** 发送短信 **/
......
/** 记录发送日志 */
......
}
}
功能测试
使用Jmeter进行并发测试,同时发送200个线程请求,通过测试发现,以上3种方式均可避免并发请求问题
查看结果,只有第一个请求发送了短信,剩余199个请求均返回失败
性能测试
使用Jmeter进行并发测试,同时发送200个线程请求,查看聚合报告:
1、使用Redis计数器方式
2、使用全局synchronized 方式
3、使用号码颗粒纬度synchronized 方式
通过测试发现,使用Redis计数器方式,并发性能最好;由于测试是使用相同号码进行测试的,第2、3两种方式测试结果差不多,实际应用中,第三种的性能肯定要高于第二种的。不过总的来说,使用锁机制性能是比较差的。