用Redis实现短链接点击统计

公众号:爱编码

背景

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

下期分享如何设计一个小型的短链接小模块设计。就像上面的参考链接这样子。
如果喜欢这篇文章的麻烦点赞一下下哈。

你可能感兴趣的:(用Redis实现短链接点击统计)