虽然标题名为防止短信炸弹,但其实这就是个限流的问题——也就是限制某个接口的时间窗口内的请求数。
1. 概述
本人所在的公司属于传统的国土行业,技术以求稳为主,而且为了兼顾实施人员的开发水平,至今连MQ都不敢往系统里集成(有人能拉一曲《二泉映月》吗?)。
抱怨到此为止,接下来我们就看看实现思路。
2. 思路
我们先来看看目前比较流行的思路:
1. 限制每个手机号的每日发送次数,超过次数则拒发送,提示超过当日次数。
2. 每个ip限制最大限制次数。超过次数则拒发送,提示超过ip当日发送最大次数。
3. 限制每个手机号发送的时间间隔,比如两分钟,没超过2分钟,不允许发送,提示操作频繁。
4. 发送短信增加图片验证码,服务端和输入验证码对比,不一致则拒绝发送。
思路基本上就是这么多了,现在唯一的问题就是挑选出更适合业务场景的。
3. 实现
我们选择使用Servlet Filter机制来完成限流功能,注意该Filter需要注册到web.xml
中,且作为第一个Filter。
/**
* 限流
* @author LQ
*
*/
public final class RateLimitFilter implements Filter {
private static final Logger LOG = Logger.getLogger(LoginFilter.class);
private int contextPathLength;
// Spring提供的缓存接口
private Cache rateLimitCache;
// 时间窗口内的限制访问次数; 也应该外置化
private final int LIMIT = 2;
@Override
public void destroy() {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterchain) throws IOException, ServletException {
final HttpServletRequest request = (HttpServletRequest) servletRequest;
final HttpServletResponse response = (HttpServletResponse) servletResponse;
String requestUri = request.getRequestURI();
if (contextPathLength != 0) {
requestUri = requestUri.substring(contextPathLength);
}
// 这里应该使用配置化
if (!"/xx/yy/zzz.do".equalsIgnoreCase(requestUri)) {
filterchain.doFilter(servletRequest, servletResponse);
return;
}
makesureCacheExist();
final String sjh = WebUtils.findParameterValue(request, "SJH");
// CacheKey就是关键所在
final CacheKey cacheKey = new CacheKey.Builder(sjh).ip(HtmlUtil.getClientIp(request))
.build();
// putIfAbsent方法报错
if (ObjectUtil.isNull(rateLimitCache.get(cacheKey, AtomicLong.class))) {
LOG.debug(StringUtil.format("手机号[ {} ]在时间点[ {} ]第一次访问.", sjh, DateTime.now()
.toString("yyyy-MM-dd HH:mm:ss")));
rateLimitCache.put(cacheKey, new AtomicLong(0));
}
final AtomicLong countPerOneMin = rateLimitCache.get(cacheKey, AtomicLong.class);
if (countPerOneMin.incrementAndGet() > LIMIT) {
LOG.debug(StringUtil.format("手机号[ {} ]被限流, 当前时间 : [ {} ]", sjh, DateTime.now()
.toString("yyyy-MM-dd HH:mm:ss")));
HtmlUtil.writerJson(response, Collections.singletonMap("BL", false));
} else {
filterchain.doFilter(servletRequest, servletResponse);
}
}
private void makesureCacheExist() {
if (ObjectUtil.isNull(rateLimitCache)) {
try {
rateLimitCache = ((CacheManager) SpringBeanFactory.getBean("cacheManager"))
.getCache("rateLimitCache");
} catch (Exception e) {
throw ExceptionUtil.wrapRuntime(e);
}
}
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
String contextPath = filterConfig.getServletContext().getContextPath();
contextPathLength = (contextPath == null || "/".equals(contextPath) ? 0 : contextPath
.length());
}
}
附上CacheKey
的相关代码
/**
* <p> 限流缓存的Key
* <p> 以手机号和当前分钟数为检索Key, 加入时间是为了出现不断访问缓存导致一直刷新的问题
* <p> 目前在时间刻度里的一分钟内只允许访问固定的次数, 即在上一分钟的最后一秒访问后, 跳到下一分钟后重新开始计算次数(依然是两次限制)
* <p> 注意其equal和hashcode方法, 里面的逻辑正是关键
* @author LQ
*
*/
final class CacheKey implements Serializable {
private static final long serialVersionUID = -1;
private String ip;
private String phoneNum;
private long currentMinute;
public static class Builder {
private final CacheKey key = new CacheKey();
public Builder(final String phoneNum) {
key.phoneNum = phoneNum;
key.currentMinute = System.currentTimeMillis() / 1000 / 60;
}
public Builder ip(final String ip) {
key.ip = ip;
return this;
}
public CacheKey build() {
return key;
}
}
public String getIp() {
return ip;
}
public String getPhoneNum() {
return phoneNum;
}
/**
* @return 获取生成该Cache时的分钟数
*/
public long getCurrentMinute() {
return currentMinute;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (other == null || other.getClass() != this.getClass()) {
return false;
}
CacheKey that = (CacheKey) other;
// custom logic -- self property; 比较自身的特有属性;父类的就交给父类.
// 核心逻辑就是这里了
// 目前的逻辑是相同手机号, 分钟数一样; 可依据自身业务需求进行自定义更改
if (this.phoneNum.equalsIgnoreCase(that.phoneNum)
&& Long.compare(this.currentMinute, that.currentMinute) == 0) {
return true;
}
// -------------------------------- 2018/9/14补充(start)
if (this.ip.equalsIgnoreCase(that.ip)
&& Long.compare(this.currentMinute, that.currentMinute) == 0) {
return true;
}
// -------------------------------- 2018/9/14补充(end)
//其它的比较交给父类
return super.equals(other);
}
@Override
public int hashCode() {
return HashCodeBuilder.reflectionHashCode(this);
}
}
4. 补充(2018/9/14)
本以为这件事就告一段落了,没想到最近又接到“还需要针对IP进行限制”的新需求,其实在这个需求最开始的时候就考虑到了这种可能性,所以预想很轻松就能解决——只需要在CacheKey
已覆写的equal
方法中添加相应的判等逻辑即可满足需求,但最终发现只是自己的一厢情愿。
基础扎实的读者应该已经猜根源所在了;在上面的CacheKey
的实现中,hashCode
方法的实现我们直接借用了Apache提供的HashCodeBuilder.reflectionHashCode(this)
以简化代码,在新的需求下,我们就需要稍微思考下关于这个方法的实现了。
这个问题归根到底就是 “哈希表是一个链表的数组”,其中hashcode()的返回值作为数组的索引,而equal()方法则是作为链表中取值的依据。 所以这里我们修改了equal里的判等逻辑之后,一定要去hashcode()中进行相应的修改以适配equal()中的逻辑变化(最暴力的方式就是return 1;
,当然这就完全失去了hash的优势。)
另外通过观察ehcache的底层源码SimpleBackend.get()
,内部就是通过ConcurrentHashMap
支撑的。而相应的Guava中的LocalCache.get()
实现中也是类似的逻辑。
5. 总结
- 其实没啥值得总结的,唯一注意的就是需要清楚基类Object里提供的equals的作用。
- 越来越觉得“用到再去学”这句话的漏洞太大了,你都不知道有这么个东西,你怎么会知道它的好处,进而去学习它呢?
6. Links
- 《亿级流量网站架构核心技术》 P70