前言

okey,咱们来收尾一下,这公历纪年2022年12月31日。这是本年度的最后一篇博文。那么这篇博文主要是用来完成博文的一个拜访记数用的。

布景

这个是我现在还在写的一个项目,没办法工作太多,加上最近状态很欠好所以一向在做。那么这个的话便是这个:

如何稍微优雅滴完成博文访问计数[SpringBoot+redis+分布式锁]
咱们要对这个阅读做到一个实时的计算。

朴素做法

文章阅读量计算,最朴素的做法便是:用户每次阅读,前端会发送一个GET恳求获取一篇文章详情时,会把这篇文章的阅读量+1,存进数据库里。

剖析存在的问题:

在GET恳求的事务逻辑里进行了数据的写操作! 高并发,数据库压力太大,文章阅读量+1会存在线程不安全问题,加锁会很慢。 一起假如文章的一些数据做了缓存操作,没有及时更新缓存傍边的数据,会导致数据不共同的状况。

Redis计划

惯例一点的思路,或许根本一点的做法能够和redis进行一个结合。

如何稍微优雅滴完成博文访问计数[SpringBoot+redis+分布式锁]
咱们将拜访的流量给缓存在Redis傍边,当到达某一个阈值,例如流量为100或许其他数值的整数倍的时分,咱们就改写数据到数据库傍边。这一来下降了关于写Mysql写的操作。一起按照咱们的博文来说,博文的内容在短时间内是不太会发生变动的,因而这个东西也应该是做缓存处理的(实际上我也是这样处理的)。因而这儿的话就有两个问题:

  1. 将拜访量在redis傍边进行存储,并且也需求做到持久化处理
  2. 坚持数据共同性,关于一些前史缓存,有必要确保里边的拜访数据是最新的

一起由于涉及到的接口较多,关于代码等级的改动有必要下降。

OK,那么接下来的话,咱们就针对这几个问题进行一个处理。

流量计算

okey,咱们先来处理第一个问题,便是咱们的一个流量的计算。 在这儿的话我是这样规划的。

如何稍微优雅滴完成博文访问计数[SpringBoot+redis+分布式锁]

可是值得一提的是,在这儿假如咱们需求严格确保数据的共同性的话,那么咱们的技术接口在拜访的时分,有必要要加上一个分布式锁,这个时分,你要考虑的便是值不值得了。假如说这个数据十分重要,那么咱们就上锁,安全,假如你认为数据并不是很重要,并且幻读是能够被允许的话,那么就没有必要去加锁。

当然咱们这儿仍是加一把锁,一起咱们这儿的完成的话也是要做到防止有人刷拜访量,毕竟这活以前干过--

那么咱们的进程的话也是看到了,其实很简单便是你首要拜访特定的接口,然后呢再去拜访博文,或许其他的一些有显现这些数据的接口,然后就能够拿到最新的一个状态。

接口演示

okey,咱们来看到咱们的接口的一个演示:

如何稍微优雅滴完成博文访问计数[SpringBoot+redis+分布式锁]
之后咱们拜访那个能够+1的接口
如何稍微优雅滴完成博文访问计数[SpringBoot+redis+分布式锁]
此时回到刚刚的接口,检查数据:
如何稍微优雅滴完成博文访问计数[SpringBoot+redis+分布式锁]
现在+1了。 这样一来咱们的根本目标就算明确了。 那么接下来咱们要做的便是完成这个东西。

自界说注解

刚刚咱们说了需求把对代码的修改降底,那么咱们就需求去运用到咱们的一个切面来做处理。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ViewNumberUp {
    //传入形式
    String mode() default "";
}

之后的话,咱们还需求去界说一下这个数据结构,便是咱们的这个流量数据长啥样:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class BlogViewNumber {
    private static final long serialVersionUID = 1L;
    private Integer viewNumber=0;
    private Integer likeNumber=0;
    private Integer collectNumber=0;
    private Integer forkNumber=0;
    private String ip;
}

这儿的话,咱们这块有4个需求计量的数据,因而的话,咱们刚刚的注解里边有一个出入形式的东西。 不过值得一提的是,处理拜访量这种东西是不能撤回的,其他的其实都是能够撤回的,比方你保藏,你能够保藏,也能够撤销保藏。这些东西的话我这儿运用相似办法处理过,可是为了让这个东西看起来“更强壮”因而我仍是做了个保留。

计数完成

那么接下来的话便是完成咱们的一个博文的计数了。

防刷

这儿防刷的手段有许多,那么我这儿的话挑选了比较简单的计划,这个计划便是经过IP去判断。假设A拜访了,那么我就几下A的IP,当B拜访的时分,我对比一下当时的IP和A的是不是一样的,假如不是那么拜访量+1,一起改写IP为B的,假如B的和A的一样,那么欠好意思不加1,我认为是同一个人。当然也能够按照你的用户的id来。游客可能没有id,那么你能够挑选分配一个暂时id,或许干脆就游客不算。

         if (!blogViewNumber.getIp().equals(ipAddr)) {
                    blogViewNumber.setViewNumber(blogViewNumber.getViewNumber() + 1);
                    blogViewNumber.setIp(ipAddr);
                }

那么在这块咱们需求运用到这个获取IP的东西类:


public class GetIPAddrUtils {
    public static HttpServletRequest GetHttpServletRequest(){
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes;
        assert servletRequestAttributes != null;
        return servletRequestAttributes.getRequest();
    }
    public static String GetIPAddr() {
        HttpServletRequest request = GetHttpServletRequest();
        return GetIPAddr(request);
    }
    public static String GetIPAddr(HttpServletRequest request) {
        String ipAddress = null;
        try {
            ipAddress = request.getHeader("x-forwarded-for");
            if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getHeader("Proxy-Client-IP");
            }
            if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getHeader("WL-Proxy-Client-IP");
            }
            if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getRemoteAddr();
                if (ipAddress.equals("127.0.0.1")) {
                    // 依据网卡取本机配置的IP
                    try {
                        ipAddress = InetAddress.getLocalHost().getHostAddress();
                    } catch (UnknownHostException e) {
                        e.printStackTrace();
                    }
                }
            }
            // 经过多个署理的状况,第一个IP为客户端实在IP,多个IP按照','切割
            if (ipAddress != null) {
                if (ipAddress.contains(",")) {
                    return ipAddress.split(",")[0];
                } else {
                    return ipAddress;
                }
            } else {
                return "";
            }
        } catch (Exception e) {
            e.printStackTrace();
            return "";
        }
    }
}

加锁

之后的话,咱们加锁,这个是没有办法的,包括咱们刚刚判断这个刷拜访量的时分也是这个问题,假如不加锁,举个例子,A,B,C三个人。A拜访了,现在拜访量是1,当B,和C一起拜访的时分,B,和C读取到的拜访量都是1,加上1之后为2,当B,C都写回去数据的时分,拜访量就2.可是实际上有3个人拜访了。这个就不准。因而得想办法,咱们有必要得加锁,可是仍是那句话,假如你认为这个是能够承受的,那么就不加锁,这样的话会节约资源。

   RLock lock = redissonClient.getLock(redisPrefix);
        lock.lock(10, TimeUnit.SECONDS);
        try {
            if(redisUtils.hasKey(redisPrefix)){
                this.addCache(redisPrefix,blogid,mode,false);
            }else {
                BlogViewNumber blogViewNumber = new BlogViewNumber();
                //经过数据库拿到博文的流量数据,然后放到咱们的redis傍边
                BlogEntity blogEntity = blogService.getById(blogid);
                BeanUtils.copyProperties(blogEntity,blogViewNumber);
                String ipAddr = GetIPAddrUtils.GetIPAddr();
                blogViewNumber.setIp(ipAddr);
                redisUtils.set(redisPrefix,blogViewNumber);
                this.addCache(redisPrefix,blogid,"pv",true);
            }
        } finally {
            lock.unlock();
        }

完整代码

这个代码的话,只需求看到一个case为pv的状况就好了,其他的是其他的,关系不大。


@Component
@Aspect
@Slf4j
public class BlogViewNumberAspect {
    private static final String viewNumberPrefix = RedisTransKey.viewNumberPrefix;
    @Autowired
    private RedisUtils redisUtils;
    @Autowired
    BlogService blogService;
    @Autowired
    RedissonClient redissonClient;
    @Value("${blog.pv}")
    private Integer blogPv;
    @Pointcut("@annotation(com.huterox.common.holeAnnotation.ViewNumberUp)")
    public void pageViewAspect() {}
    /**
     * 这儿负责处理咱们的切面,主要是处理咱们一些流量信息的记载
     * 1. 初始化时将博文的数据写缓存傍边
     * 2. 初始化后,更新缓存傍边的数据
     * 3. 阅读量比较特殊,不需求每次都改写数据库
     *      此外二外开放一个专门获取博文流量信息的接口(这个接口上的数据将从redis傍边获取)
     */
    @AfterReturning(value = "pageViewAspect()&& @annotation(viewNumberUp)",
            returning = "result")
    public void around(JoinPoint joinPoint,ViewNumberUp viewNumberUp, R result) throws Exception {
        int code = Integer.parseInt(result.get("code").toString());
        if(code!=0){
            return;
        }
        assert viewNumberUp!=null;
        String mode = viewNumberUp.mode();
        Map<String, Object> nameAndValue = getNameAndValue(joinPoint);
        Long blogid = Long.valueOf(nameAndValue.get("blogid").toString());
        String redisPrefix = viewNumberPrefix+":"+blogid;
        RLock lock = redissonClient.getLock(redisPrefix);
        lock.lock(10, TimeUnit.SECONDS);
        try {
            if(redisUtils.hasKey(redisPrefix)){
                this.addCache(redisPrefix,blogid,mode,false);
            }else {
                BlogViewNumber blogViewNumber = new BlogViewNumber();
                //经过数据库拿到博文的流量数据,然后放到咱们的redis傍边
                BlogEntity blogEntity = blogService.getById(blogid);
                BeanUtils.copyProperties(blogEntity,blogViewNumber);
                String ipAddr = GetIPAddrUtils.GetIPAddr();
                blogViewNumber.setIp(ipAddr);
                redisUtils.set(redisPrefix,blogViewNumber);
                this.addCache(redisPrefix,blogid,"pv",true);
            }
        } finally {
            lock.unlock();
        }
    }
    private void addCache(String redisPrefix,Long blogid,String mode,boolean first){
        Object o = redisUtils.get(redisPrefix);
        BlogViewNumber blogViewNumber = JSON.parseObject(o.toString(), BlogViewNumber.class);
        switch (mode) {
            case "pv":
                Integer viewNumber = blogViewNumber.getViewNumber();
                if (viewNumber % blogPv == 0) {
                    //这个时分更新数据库
                    BlogEntity blogEntity = blogService.getById(blogid);
                    blogEntity.setViewNumber(viewNumber);
                    blogService.updateById(blogEntity);
                }
                blogViewNumber.setViewNumber(blogViewNumber.getViewNumber() + 1);
            if(first) {
                blogViewNumber.setViewNumber(blogViewNumber.getViewNumber() + 1);
            }else {
                String ipAddr = GetIPAddrUtils.GetIPAddr();
                blogViewNumber.setViewNumber(blogViewNumber.getViewNumber() + 1);
                if (!blogViewNumber.getIp().equals(ipAddr)) {
                    blogViewNumber.setViewNumber(blogViewNumber.getViewNumber() + 1);
                    blogViewNumber.setIp(ipAddr);
                }
            }
                break;
            case "cv": {
                BlogEntity blogEntity = blogService.getById(blogid);
                blogViewNumber.setCollectNumber(blogViewNumber.getCollectNumber() + 1);
                blogEntity.setCollectNumber(blogViewNumber.getCollectNumber());
                blogService.updateById(blogEntity);
                break;
            }
            case "fv": {
                BlogEntity blogEntity = blogService.getById(blogid);
                blogViewNumber.setForkNumber(blogViewNumber.getForkNumber() + 1);
                blogEntity.setForkNumber(blogViewNumber.getForkNumber());
                blogService.updateById(blogEntity);
                break;
            }
            default: {
                BlogEntity blogEntity = blogService.getById(blogid);
                blogViewNumber.setLikeNumber(blogViewNumber.getLikeNumber() + 1);
                blogEntity.setLikeNumber(blogViewNumber.getLikeNumber());
                blogService.updateById(blogEntity);
                break;
            }
        }
        redisUtils.set(redisPrefix,blogViewNumber);
    }
    /**
     * 获取某个Method的参数称号及对应的值
     * @return Map<参数称号, 参数值></参数称号,参数值>
     */
    public static Map<String, Object> getNameAndValue(JoinPoint joinPoint) {
        Map<String, Object> param = new HashMap<>();
        Object[] paramValues = joinPoint.getArgs();
        String[] paramNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
        for (int i = 0; i < paramNames.length; i++) {
            param.put(paramNames[i], paramValues[i]);
        }
        return param;
    }
}

数据共同性

之后的话便是咱们的另一个要点,数据的共同性。 相同的咱们也需求运用到切面来完成操作。

剖析

首要的话,刚刚现已说了有些东西,是存放在缓存里边的,有必要确保这儿边的刚刚咱们做的流量计算是最新的,否则当用户拜访到这个缓存的时分,得到数据就不共同了。这样做的话,虽然没什么,可是实在是太难看了。

那么处理这个咱们当然其实有三个计划嘛。

  1. 处理看数据的人
  2. 处理缓存的数据
  3. 处理回来的数据

第一点行不通。 第二点,能够可是修改数据的时分需求不断更新redis里边的缓存,对redis的写入比较繁琐。 第三点,也能够,可是需求不断对回来数据进行处理,对redis的度比较繁琐。

综合来看的话,回来的数据量不是很大,并且天知道缓存里边还有其他东西没有,我很难确保全部有更新。因而我挑选了第三点,完成也比较简单。

自界说注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RefreshFlew {
    String type() default "";
    String key() default "";
}

我在这儿界说了两个值,一个是type,还有一个是key。

原因的话是这样的,咱们这边是有一个一致的回来类的。

public class R extends HashMap<String, Object> {
	private static final long serialVersionUID = 1L;
	public R() {
		put("code", 0);
		put("msg", "success");
	}
	public static R error() {
		return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "未知异常,请联系管理员");
	}
	public static R error(String msg) {
		return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg);
	}
	public static R error(int code, String msg) {
		R r = new R();
		r.put("code", code);
		r.put("msg", msg);
		return r;
	}
	public static R warn() {
		R r = new R();
		r.put("code", 1);
		r.put("msg", "warning");
		return r;
	}
	public static R ok(String msg) {
		R r = new R();
		r.put("msg", msg);
		return r;
	}
	public static R ok(Map<String, Object> map) {
		R r = new R();
		r.putAll(map);
		return r;
	}
	public static R ok() {
		return new R();
	}
	public R put(String key, Object value) {
		super.put(key, value);
		return this;
	}
}

所以的话咱们有两个东西。

回来值剖析

刚刚说了回来的东西是R这个回来类,可是R是个MAP,然后这儿边应该是啥呢。这儿的话在我的这个规划傍边呢,是这两个类的东西。 第一个是回来的是PageUtils。这个呢是MP分页一个东西,也是自界说的一个东西。

/**
 * 分页东西类
 */
public class PageUtils implements Serializable {
	private static final long serialVersionUID = 1L;
	/**
	 * 总记载数
	 */
	private int totalCount;
	/**
	 * 每页记载数
	 */
	private int pageSize;
	/**
	 * 总页数
	 */
	private int totalPage;
	/**
	 * 当时页数
	 */
	private int currPage;
	/**
	 * 列表数据
	 */
	private List<?> list;
	/**
	 * 分页
	 * @param list        列表数据
	 * @param totalCount  总记载数
	 * @param pageSize    每页记载数
	 * @param currPage    当时页数
	 */
	public PageUtils(List<?> list, int totalCount, int pageSize, int currPage) {
		this.list = list;
		this.totalCount = totalCount;
		this.pageSize = pageSize;
		this.currPage = currPage;
		this.totalPage = (int)Math.ceil((double)totalCount/pageSize);
	}
	/**
	 * 分页
	 */
	public PageUtils(IPage<?> page) {
		this.list = page.getRecords();
		this.totalCount = (int)page.getTotal();
		this.pageSize = (int)page.getSize();
		this.currPage = (int)page.getCurrent();
		this.totalPage = (int)page.getPages();
	}
	public int getTotalCount() {
		return totalCount;
	}
	public void setTotalCount(int totalCount) {
		this.totalCount = totalCount;
	}
	public int getPageSize() {
		return pageSize;
	}
	public void setPageSize(int pageSize) {
		this.pageSize = pageSize;
	}
	public int getTotalPage() {
		return totalPage;
	}
	public void setTotalPage(int totalPage) {
		this.totalPage = totalPage;
	}
	public int getCurrPage() {
		return currPage;
	}
	public void setCurrPage(int currPage) {
		this.currPage = currPage;
	}
	public List<?> getList() {
		return list;
	}
	public void setList(List<?> list) {
		this.list = list;
	}
}

还有一个东西便是咱们的这个博文的一个实体类,或许是具备相同字段的实体类。 比方这个便是有相同字段的一个实体了。回来的是这个,或许便是博文实体类。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class BlogBody implements Serializable {
    private static final long serialVersionUID = 1L;
    /**
     * 这边的话userid啥的都是指文章的作者
     * */
    private String userid;
    private Long blogid;
    private String content;
    private String blogTitle;
    private String userNickname;
    private String userImg;
    private String createTime;
    private Integer viewNumber;
    private Integer likeNumber;
    private Integer collectNumber;
    private Integer forkNumber;
    private String blogtype;
    private String blogimg;
}

一起部分的接口,仍是用了缓存的,这儿运用的是SpringCache作为缓存技术。(确切的说是大部分接口,尤有钱就上ES(艹皿艹 ))

处理计划

那么这个时分咱们就需求对这些接口的回来值进行处理了,这儿值得庆幸的便是咱们有一致回来类。因而的话不管怎么样,办法回来的一定是R类的东西。咱们要做的便是分别处理刚刚提到的两种类型的数据。这个的话咱们就直接看到代码了,思路仍是简单的。


@Component
@Aspect
@Slf4j
public class ReFreshFlewAspect {
    private static final String viewNumberPrefix = RedisTransKey.viewNumberPrefix;
    @Autowired
    private RedisUtils redisUtils;
    @Autowired
    BlogService blogService;
    @Pointcut("@annotation(com.huterox.common.holeAnnotation.RefreshFlew)")
    public void refreshAspect() {}
    @Around("refreshAspect() && @annotation(annotation)")
    public R verification(ProceedingJoinPoint joinPoint,RefreshFlew annotation) throws Throwable{
        assert annotation != null;
        String type = annotation.type();
        String key = annotation.key();
        Object pro = joinPoint.proceed();
        //咱们这儿最后一个依托的是SpringCache的回来值,这个时分人家现已帮咱们从头序列化为了一个R目标
        R proceed = (R) pro;
        if(type.equals("page")){
            PageUtils page = (PageUtils) proceed.get(key);
            List<BlogEntity> blogEntityList = (List<BlogEntity>) page.getList();
            List<BlogEntity> result = new ArrayList<>();
            for(BlogEntity blogEntity:blogEntityList){
                String redisPrefix = viewNumberPrefix+":"+blogEntity.getBlogid();
                if(redisUtils.hasKey(redisPrefix)){
                    Object red = redisUtils.get(redisPrefix);
                    BlogViewNumber blogViewNumber = JSON.parseObject(red.toString(), BlogViewNumber.class);
                    BeanUtils.copyProperties(blogViewNumber,blogEntity);
                    result.add(blogEntity);
                }else {
                    this.createCache(blogEntity,redisPrefix);
                    result.add(blogEntity);
                }
            }
            page.setList(result);
            proceed.put(key,page);
        }else if(type.equals("blog")){
            Object o =  proceed.get(key);
            BlogEntity ob = new BlogEntity();
            BeanUtils.copyProperties(o,ob);
            String redisPrefix = viewNumberPrefix+":"+ob.getBlogid();
            if(redisUtils.hasKey(redisPrefix)){
                Object red = redisUtils.get(redisPrefix);
                BlogViewNumber blogViewNumber = JSON.parseObject(red.toString(), BlogViewNumber.class);
                BeanUtils.copyProperties(blogViewNumber,o);
                proceed.put(key,o);
            }else {
                this.createCache(ob,redisPrefix);
            }
        }
        return proceed;
    }
    private void createCache(BlogEntity o,String redisPrefix){
        BlogViewNumber blogViewNumber = new BlogViewNumber();
        BeanUtils.copyProperties(o,blogViewNumber);
        blogViewNumber.setIp("Hello 2023");
        redisUtils.set(redisPrefix,blogViewNumber);
    }
}

这儿的话就没必要上锁了,否则我就用读写锁了,为啥呢,因为自身有网络的延迟,在你获取数据的进程傍边,可能又有人拜访了,这个时分拜访数据得到的也不是那么准了。所以这个也是为什么一开始我便是你能不能忍受,因为这个数据自身不会准(假如拜访人多了)可是能够确保记载的准确性。这个假如是商品之类的东西的话,那么就十分有必要了。

总结

最后祝我们新年快乐~ 不说了,喉咙要废了。