前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >用Redis实现短链接点击统计

用Redis实现短链接点击统计

作者头像
用户3467126
发布2020-12-01 11:05:53
1K0
发布2020-12-01 11:05:53
举报
文章被收录于专栏:爱编码爱编码

背景

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、外部接收点击请求:

代码语言:javascript
复制
 @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、定时任务处理点击数入库:

代码语言:javascript
复制
/**
 * 统计短链接定时任务
 */
@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<String, Integer> urlMap = new HashMap<>();
            Long batchSize = 10000L;
            do {
                Long pageSize = size > batchSize ? batchSize : size;
                List<String> 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<String>() {
                    @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 是有时效性的,过期就会删除。

核心详细代码如下:

代码语言:javascript
复制
/**
 * 请求拦截
 */
@Slf4j
@Component
public class RequestLimitIntercept extends HandlerInterceptorAdapter {

    @Autowired
    private RedisTemplate<String,Object> 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

下期分享如何设计一个小型的短链接小模块设计。就像上面的参考链接这样子。

如果喜欢这篇文章的麻烦点赞一下下哈。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-11-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 爱编码 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 背景
  • 问题分析
  • 解决方案
    • 缓存点击数异步入库
      • 接口IP防刷
      相关产品与服务
      云数据库 Redis
      腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档