背景
Hello, everyone,long time no see.
事情发生在9月8号晚淘宝促销活动,短链接应用突然数据库连接飙升,监控中发现有SQL在疯狂地更新,其中有一条就是更新短链接的点击数。查看了该接口功能其实非常简单:判断ip是否合法,然后短链接的点击数+1,更新到数据库表。
问题分析
接口功能虽然简单,但如果是在统计几个淘宝超级卖家的会员点击数的时候,我们如果稍不注意就容易将系统给搞垮。从上可以得出以下问题:
1、短链接是直接更新到数据库,并发量过高时会增加数据库的压力,进而影响其他业务。
2、接口仅仅做了ip校验,没有任何高并发和防刷限制,容易被外部攻击。
解决方案
缓存点击数异步入库
由于需求是需要实时更新点击数据,所以不能缓存太久。
1、使用mq就可以实现对流量消峰,达到异步处理的效果,但是项目中mq主要是rabbitmq,在大量堆积的情况下效果又不太好。(如果你的是rocketmq,那么当然首选是它了)
2、使用redis其实也可以实现类似的效果。
- 2.1、只需要将点击的链接id+ip使用rPush到一个redis的list集合中。
- 2.2、开启线程定时1min执行一次,获取当前redis的list的llen总长度。
- 2.3、每次取出最大不超过1w条点击数据进行统计,并批量更新点击数。
- 2.4、统计完毕后,使用redis管道循环将刚处理完毕的1w条数据弹出lpop即可。
- 2.5、循环3、4步至到取到llen条点击数。
此处要确保第二步和第三步是在同一个事务中,否则容易出现计算重复的情况。
一条点击数据=短链接id+ip,大约25个字节,其实1个G的redis内存就可以存下4千万人点一下接口的量,具体要预估数据量加内存或者做取舍。(老板给了5个G,不够就丢弃的策略。)
或许有人会说,后面能多线程处理就好了。其实每次处理1w条,如果1min内有1个亿的点击量,其实只需要执行1w更新操作即可,整个流程只有入库耗时占大部分,1min其实1w次循环还是可以实现的,没必要开多线程带来更多并发问题(如并发更新同一行容易锁表)。
核心代码:
1、外部接收点击请求:
@Override
public String visitLink(String shortUrl) {
if (StringUtils.isEmpty(shortUrl)) {
return null;
}
//此处可以将最近1天生成的短链接加入到缓存,提高响应速度。
//将点击数缓存,使用异步线程批量更新。
String resultStr = redisUtil.get(RedisKey.LINK_LIST_LAST + shortUrl);
if (!StringUtils.isEmpty(resultStr)) {
redisUtil.lRightPush(RedisKey.LINK_CLICK_COUNT, shortUrl);
return resultStr;
}
switch (shortUrl.length()) {
case 4:
//极短链接
MinShortUrl originUrl = minShortUrlMapper.getOriginUrl(shortUrl);
if (originUrl != null) {
minShortUrlMapper.updateShortUrl(originUrl);
}
resultStr = originUrl.getUrl();
break;
case 6:
//普通短链接
ShortUrl oUrl = shortUrlMapper.getOriginUrl(shortUrl);
if (oUrl != null) {
shortUrlMapper.updateShortUrl(oUrl);
}
resultStr = oUrl.getUrl();
break;
default:
break;
}
if (!StringUtils.isEmpty(resultStr)) {
redisUtil.setEx(RedisKey.LINK_LIST_LAST + shortUrl, resultStr, 1, TimeUnit.DAYS);
}
return resultStr;
}
2、定时任务处理点击数入库:
/**
* 统计短链接定时任务
*/
@Component
public class ShortUrlSchedule {
@Autowired
private RedisUtil redisUtil;
@Autowired
ShortUrlService shortUrlService;
//每10分钟执行一次
@Scheduled(cron = "0 0/10 * * * ? ")
@Transactional(rollbackFor = Exception.class)
public void calculateClickCount() {
Long size = redisUtil.size(RedisKey.LINK_CLICK_COUNT);
if (size != null && size > 0) {
//统计短链接点击数
Map urlMap = new HashMap<>();
Long batchSize = 10000L;
do {
Long pageSize = size > batchSize ? batchSize : size;
List tmpList = redisUtil.lRange(RedisKey.LINK_CLICK_COUNT, 0, pageSize);
if (CollectionUtils.isEmpty(tmpList)) {
return;
}
for (String shortUrl : tmpList) {
//处理短链接被点击数
Integer count = urlMap.get(shortUrl);
if (count == null || count == 0) {
count = 0;
}
urlMap.put(shortUrl, ++count);
}
//批量更新
int i = shortUrlService.batchUpdateClickCount(urlMap);
//弹出
redisUtil.getRedisTemplate().executePipelined(new RedisCallback() {
@Override
public String doInRedis(RedisConnection redisConnection) throws DataAccessException {
RedisConnection pl = redisConnection;
for (int i = 0; i <= tmpList.size(); i++) {
pl.lPop(RedisKey.LINK_CLICK_COUNT.getBytes());
}
return null;
}
});
size = size - tmpList.size();
} while (size > 0);
}
}
}
接口IP防刷
问题:想让某个接口某个人在某段时间内只能请求N次。
原理:在你请求的时候,服务器通过redis 记录下你请求的次数,如果次数超过限制就不给访问。 在redis 保存的key 是有时效性的,过期就会删除。
核心详细代码如下:
/**
* 请求拦截
*/
@Slf4j
@Component
public class RequestLimitIntercept extends HandlerInterceptorAdapter {
@Autowired
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
/**
* isAssignableFrom() 判定此 Class 对象所表示的类或接口与指定的 Class 参数所表示的类或接口是否相同,或是否是其超类或超接口
* isAssignableFrom()方法是判断是否为某个类的父类
* instanceof关键字是判断是否某个类的子类
*/
if(handler.getClass().isAssignableFrom(HandlerMethod.class)){
//HandlerMethod 封装方法定义相关的信息,如类,方法,参数等
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
// 获取方法中是否包含注解
RequestLimit methodAnnotation = method.getAnnotation(RequestLimit.class);
//获取 类中是否包含注解,也就是controller 是否有注解
RequestLimit classAnnotation = method.getDeclaringClass().getAnnotation(RequestLimit.class);
// 如果 方法上有注解就优先选择方法上的参数,否则类上的参数
RequestLimit requestLimit = methodAnnotation != null?methodAnnotation:classAnnotation;
if(requestLimit != null){
if(isLimit(request,requestLimit)){
resonseOut(response,Result.error(ApiResultEnum.REQUST_LIMIT));
return false;
}
}
}
return super.preHandle(request, response, handler);
}
//判断请求是否受限
public boolean isLimit(HttpServletRequest request,RequestLimit requestLimit){
// 受限的redis 缓存key ,因为这里用浏览器做测试,我就用sessionid 来做唯一key,如果是app ,可以使用 用户ID 之类的唯一标识。
String limitKey = request.getServletPath()+request.getSession().getId();
// 从缓存中获取,当前这个请求访问了几次
Integer redisCount = (Integer) redisTemplate.opsForValue().get(limitKey);
if(redisCount == null){
//初始 次数
redisTemplate.opsForValue().set(limitKey,1,requestLimit.second(), TimeUnit.SECONDS);
}else{
if(redisCount.intValue() >= requestLimit.maxCount()){
return true;
}
// 次数自增
redisTemplate.opsForValue().increment(limitKey);
}
return false;
}
/**
* 回写给客户端
* @param response
* @param result
* @throws IOException
*/
private void resonseOut(HttpServletResponse response, Result result) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter out = null ;
String json = JSONObject.toJSON(result).toString();
out = response.getWriter();
out.append(json);
}
}
详情可以参考文章:
xbmchina.cn/AAAAAD
下期分享如何设计一个小型的短链接小模块设计。就像上面的参考链接这样子。
如果喜欢这篇文章的麻烦点赞一下下哈。