SpringBoot系列教程应用篇之借助Redis搭建一个简单站点统计服务

判断一个网站值不值钱的一个重要标准就是看pv/uv,那么你知道pv,uv是怎么统计的么?固然如今有第三方作的比较完善的能够直接使用,但若是让咱们本身来实现这么一个功能,应该怎么作呢?java

本篇内容较长,源码如右 ➡️ github.com/liuyueyi/sp…git

I. 背景及需求

为了看看个人博客是否是我一我的的单机游戏,因此就想着统计一下总的访问量,每日的访问人数,哪些博文又是你们感兴趣的,点击得多的;github

所以就萌发了本身撸一个pv/uv统计的服务,固然我这个也不须要特别完善高大上,能知足我本身的基本须要就能够了web

  • 但愿统计站点(域名)总访问次数
  • 但愿统计站点总的访问人数,当前访问者在访问人数中的排名(即这个ip是全部访问ip中的第多少位访问的这个站点)
  • 每一个子页面都有访问次数,访问总人数,当前ip访问的排名统计
  • 同一个ip,同一天内访问同一个子页面,pv次数只加1次;隔天以后,再次访问pv+1

II. 方案设计

前面的背景和需求,能够说大体说明了咱们要作个什么东西,以及须要注意哪些事项,再进行方案设计的过程当中,则须要对需求进行详细拆解redis

1. 术语说明

前面提到了pv,uv,在咱们的实际实现中,会发现这个服务中对于pv,uv的定义和标准定义并非彻底一致的,下面进行说明spring

a. pv

page viste, 每一个页面的访问次数,在本服务中,咱们的pv指的是总量,即从开始接入时,到如今总的访问次数api

可是这里有个限制: 一个合法的ip,一天以内pv统计次数只能+1次数组

  • 根据ip进行区分,所以须要获取访问者ip
  • 同一天内,这个ip访问相同的URI,只能算一次有效pv;次日以后,再次访问,则能够再算一次有效pv

b. hot

前面的pv针对ip进行了限制,一个ip同一天的访问,只能计算一次,大部分状况下这种统计并无什么问题,可是若是一个文章写得特别有参考意义,致使有人重复的看,仔细的看,换着花样的刷新看,这个时候统计下总的访问次数是否是也挺好的安全

所以在这个服务中,引入了hot(热度)的概念,对于一个uri而言,只要一次点击,hot+1bash

c. uv

unique visitor, 这个就是统计URI的访问ip数

2. 流程图

经过前面三个术语的定义,咱们的操做流程就相对清晰了,咱们的服务接收一个IP和URI,而后操做对应的pv,uv,hot并返回

  • 首先判断这个ip是否为第一次访问这个URI
  • 是,则pv+1, uv+1, hot+1
  • 否,表示以前访问过,uv就不能变了
    • 判断是否今天第一次访问
    • 是,今天访问过,那么pv不变,hot+1
    • 否,以前访问过,今天没有,pv能够+1, hot+1

对应的流程图以下

流程图

3. 数据结构

流程清晰以后,接下来就须要看下pv,uv,hot三个数据怎么存了

a. pv

pv保存的就是访问次数,与ip无关,因此kv存储就能够知足咱们的需求了,这里的key为uri,value则保存pv的值

b. hot

hot和pv相似,一样用kv能够知足要求

c. uv

uv这里有两个数据,一个是uv总数,要给是这个ip的访问排名,redis中有个zset数据结构正好就能够作这个

zset数据结构中,咱们定义value为ip,score为ip的排名,那么uv就是最大的score了

d. 结构图

流程图

4. 方案设计

流程清晰,结构设计出来以后,就能够进入具体的方案设计环节了,在这个环节中,咱们引入一个app的维度,这样咱们的服务就能够通用了;

每一个使用者都申请一个app,那么这个使用者的请求的全部站点统计数据,都关联到这个app上,这样也有利于后续统计了

a. 接口API

引入了app以后,结合前面的两个参数ip + URI,咱们的请求参数就清晰了

@Data
public class VisitReqDTO {
    /** * 应用区分 */
    private String app;

    /** * 访问者ip */
    private String ip;

    /** * 访问的URI */
    private String uri;
}
复制代码

而后咱们返回的数据,pv + uv + rank + hot,因此返回的基础VO以下

/** * Created by @author yihui in 16:19 19/5/12. */
@Data
@AllArgsConstructor
public class VisitVO implements Serializable {
    /** * pv,与传统的有点区别,这里表示这个url的总访问次数;每一个ip,一天次数只+1 */
    private Long pv;

    /** * uv 页面总的ip访问数 */
    private Long uv;

    /** * 当前ip,第一次访问本url的排名 */
    private Long rank;

    /** * 热度,每次访问计数都+1 */
    private Long hot;

    public VisitVO() {
    }

    public VisitVO(VisitVO visitVO) {
        this.pv = visitVO.pv;
        this.uv = visitVO.uv;
        this.rank = visitVO.rank;
        this.hot = visitVO.hot;
    }
}
复制代码

此外须要注意一点的是,发起一个子页面的请求时,这个时候咱们基于域名的站点总数统计也应该被触发(简单来讲,访问http://spring.hhui.top/spring-blog/时,不只这个uri的统计须要更新, spring.hhui.top这个域名的pv,uv,hot也须要随之统计)

所以咱们最终的返回对象应该是

@Data
@NoArgsConstructor
@AllArgsConstructor
public class SiteVisitDTO {

    /** * 站点访问统计 */
    private VisitVO siteVO;

    /** * 页面访问统计 */
    private VisitVO uriVO;

}
复制代码

有输出,又返回,那么访问api就简单了

SiteVisitDTO visit(VisitReqDTO reqDTO);
复制代码

b. hot相关api

hot数据结构为hash,每次请求过来,都是次数+1,所以直接使用redis的 hIncrBy,实现计数+1,并返回最终的计数

  • key: "hot_cnt_" + app 做为hash的key
  • field: 使用URI做为hash的field
  • value: 保存具体的hot,整型

hot api

/** * 应用的热度统计计数 * * @param app * @return */
private String buildHotKey(String app) {
    return "hot_cnt_" + app;
}

/** * 热度,每访问一次,计数都+1 * * @param key * @param uri * @return */
public Long addHot(String key, String uri);
复制代码

c. pv相关api

pv与hot不同的是并非每次都须要计数+1,因此它须要有一个查询pv的接口,和一个计数+1的接口

  • key: "site_cnt_" + app 做为hash的key
  • field: 使用URI做为hash的field
  • value: 保存具体的pv,整型

pv api

/** * 应用的pv统计计数 * * @param app * @return */
private String buildPvKey(String app) {
    return "site_cnt_" + app;
}

/** * 获取pv * * pv存储结果为hash,一个应用一个key; field 为uri; value为pv * * @return null表示首次有人访问;这个时候须要+1 */
public Long getPv(String key, String uri);


/** * pv 次数+1 * * @param key * @param uri */
public void addPv(String key, String uri) 复制代码

d. uv相关api

前面说到uv采用的是zset数据结构,其中ip做为value,排名做为score;因此uv就是最大的score

  • key: 根据app和uri来肯定uv的key
  • value: 存储访问者ip(ipv4格式的)
  • score: 排名,整型

uv api

由于uv须要返回两个结构,因此咱们的返回须要注意

/**
 * app+uri 对应的uv
 *
 * @param app
 * @param uri
 * @return
 */
private String buildUvKey(String app, String uri) {
    return "uri_rank_" + app + "_" + uri;
}


/**
 * 获取uri对应的uv,以及当前访问ip的历史访问排名
 * 使用zset来存储,key为uri惟一标识;value为ip;score为访问的排名
 *
 * @param key : 由app与URI来生成,即一个uri维护一个uv集
 * @param ip: 访问者ip
 * @return 返回uv/rank, 若是对应的值为0,表示没有访问过
 */
public ImmutablePair</** uv */Long, /** rank */Long> getUv(String key, String ip) 

/**
 * uv +1
 *
 * @param key
 * @param ip
 * @param rank
 */
public void addUv(String key, String ip, Long rank) 
复制代码

e. 今日是否访问

前面的都还算比较简单,接下来有个很是有意思的地方了,如何判断这个ip,今天访问没访问?

方案一

要实现这个功能,一个天然而然的想法就出来了,直接kv就好了

  • key: uri_年月日_ip
  • value: 1

若是value存在,表示今天访问过,若是不存在,则没有访问过

方案二

前面那个却是没啥问题,若是我但愿统计今天某个uri的ip访问数,上面的就不太好处理,很容易想到用hash来替换

  • key: uri_年月日
  • field: ip
  • value: 1

一样value存在,则表示今天访问过;不然没有访问过

若是须要统计今天访问的总数,hlen一把就能够;还能够获取今天全部访问过的ip

方案三

前面的方案看似挺好的,可是有个缺陷,若是我这个站点特别火,天天几百万的uv,这个存储量就有点夸张了

# 简单的算一下 10w uv的存储开销
field: ip   # 一个ip(255.255.255.255) 字符串存储算 16B;
value: 1  # 算 1B

10w uv = 10w * 17B = 1.7MB

# 假设这个站点有100个10w uv的子页面,天天存储须要 170MB
复制代码

经过上面简单的计算能够看出这存储开销对于比较火的站点而言,有点吓人;而后能够找其余的存储方式了,因此bitmap能够隆重登场了

bitmap

咱们将位数组分红四节,分别于ip的四段对应,由于ipv4每一段取值是(0-2^8),因此咱们的位数组,也只须要(4 * 8b = 4B),相比较前面的方案来讲,存储空间大大减小

看到上面这个结构,会有一个疑问,为何分红四节?将ip转成整形,做为下标,一个就能够了

  • 答:将ip转为整型,取值将是 (0 - 2^32),须要的bitmap空间为4Gb,显然不如上面优雅

方案肯定

上面三个方案中,咱们选择了第三个,对应的api设计也比较简单了

// 获取今天的日期,格式为 20190512
public static String getToday() {
    LocalDate date = LocalDate.now();
    int year = date.getYear();
    int month = date.getMonthValue();
    int day = date.getDayOfMonth();

    StringBuilder buf = new StringBuilder(8);
    return buf.append(year).append(month < 10 ? "0" : "").append(month).append(day < 10 ? "0" : "").append(day)
            .toString();
}
    
    
/** * 每日访问统计 * * @param app * @param uri * @return */
private String buildUriTagKey(String app, String uri) {
    return "uri_tag_" + DateUtil.getToday() + "_" + app + "_" + uri;
}


/** * 标记ip访问过这个key * * @param key * @param ip */
public void tagVisit(String key, String ip) 复制代码

III. 服务实现

前面接口设计出来,按照既定思路实现就属于比较轻松的环节了

1. pv接口实现

pv两个接口,一个访问,一个计数+1,均可以直接使用redisTemplate的基础操做完成

/** * 获取pv * * pv存储结果为hash,一个应用一个key; field 为uri; value为pv * * @return null表示首次有人访问;这个时候须要+1 */
public Long getPv(String key, String uri) {
    return redisTemplate.execute(new RedisCallback<Long>() {
        @Override
        public Long doInRedis(RedisConnection connection) throws DataAccessException {
            byte[] ans = connection.hGet(key.getBytes(), uri.getBytes());
            if (ans == null || ans.length == 0) {
                return null;
            }

            return Long.parseLong(new String(ans));
        }
    });
}

/** * pv 次数+1 * * @param key * @param uri */
public void addPv(String key, String uri) {
    redisTemplate.execute(new RedisCallback<Void>() {
        @Override
        public Void doInRedis(RedisConnection connection) throws DataAccessException {
            connection.hIncrBy(key.getBytes(), uri.getBytes(), 1);
            return null;
        }
    });
}
复制代码

2. hot接口实现

只有一个计数+1的接口

/** * 热度,每访问一次,计数都+1 * * @param key * @param uri * @return */
public Long addHot(String key, String uri) {
    return redisTemplate.execute(new RedisCallback<Long>() {
        @Override
        public Long doInRedis(RedisConnection connection) throws DataAccessException {
            return connection.hIncrBy(key.getBytes(), uri.getBytes(), 1);
        }
    });
}
复制代码

3. uv接口实现

uv的获取会麻烦一点,首先获取uv值,而后获取ip对应的排名;若是uv为0,排名也就不须要再获取了

/**
 * 获取uri对应的uv,以及当前访问ip的历史访问排名
 * 使用zset来存储,key为uri惟一标识;value为ip;score为访问的排名
 *
 * @param key : 由app与URI来生成,即一个uri维护一个uv集
 * @param ip: 访问者ip
 * @return 返回uv/rank, 若是对应的值为0,表示没有访问过
 */
public ImmutablePair</** uv */Long, /** rank */Long> getUv(String key, String ip) {
    // 获取总uv数,也就是最大的score
    Long uv = redisTemplate.execute(new RedisCallback<Long>() {
        @Override
        public Long doInRedis(RedisConnection connection) throws DataAccessException {
            Set<RedisZSetCommands.Tuple> set = connection.zRangeWithScores(key.getBytes(), -1, -1);
            if (CollectionUtils.isEmpty(set)) {
                return 0L;
            }

            Double score = set.stream().findFirst().get().getScore();
            return score.longValue();
        }
    });

    if (uv == null || uv == 0L) {
        // 表示尚未人访问过
        return ImmutablePair.of(0L, 0L);
    }

    // 获取ip对应的访问排名
    Long rank = redisTemplate.execute(new RedisCallback<Long>() {
        @Override
        public Long doInRedis(RedisConnection connection) throws DataAccessException {
            Double score = connection.zScore(key.getBytes(), ip.getBytes());
            return score == null ? 0L : score.longValue();
        }
    });

    return ImmutablePair.of(uv, rank);
}

/**
 * uv +1
 *
 * @param key
 * @param ip
 * @param rank
 */
public void addUv(String key, String ip, Long rank) {
    redisTemplate.execute(new RedisCallback<Void>() {
        @Override
        public Void doInRedis(RedisConnection connection) throws DataAccessException {
            connection.zAdd(key.getBytes(), rank, ip.getBytes());
            return null;
        }
    });
}
复制代码

4. 今天是否访问过

前面选择位数组方式来记录是否访问过,这里的实现选择了简单的实现方式,利用四个bitmap来分别对应ip的四段;(实际上一个也能够实现,能够想想应该怎么作)

/** * 判断ip今天是否访问过 * 采用bitset来判断ip是否有访问,key由app与uri惟一肯定 * * @return true 表示今天访问过/ false 表示今天没有访问过 */
public boolean visitToday(String key, String ip) {
    // ip地址进行分段 127.0.0.1
    String[] segments = StringUtils.split(ip, ".");
    for (int i = 0; i < segments.length; i++) {
        if (!contain(key + "_" + i, Integer.valueOf(segments[i]))) {
            return false;
        }
    }
    return true;
}

private boolean contain(String key, Integer val) {
    return redisTemplate.execute(new RedisCallback<Boolean>() {
        @Override
        public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
            return connection.getBit(key.getBytes(), val);
        }
    });
}


/** * 标记ip访问过这个key * * @param key * @param ip */
public void tagVisit(String key, String ip) {
    String[] segments = StringUtils.split(ip, ".");
    for (int i = 0; i < segments.length; i++) {
        int finalI = i;
        redisTemplate.execute(new RedisCallback<Void>() {
            @Override
            public Void doInRedis(RedisConnection connection) throws DataAccessException {
                connection.setBit((key + "_" + finalI).getBytes(), Integer.valueOf(segments[finalI]), true);
                return null;
            }
        });

    }
}
复制代码

4. api接口实现

前面基本的接口实现以后,api就是流程图的翻译了,也没有什么特别值得说到的地方,惟一须要注意的就是URI的解析,域名做为站点;uri由path + segment构成

public static ImmutablePair</**host*/String, /**uri*/String> foramtUri(String uri) {
    URI u = URI.create(uri);
    String host = u.getHost();
    if (u.getPort() > 0 && u.getPort() != 80) {
        host = host + ":80";
    }

    String baseUri = u.getPath();
    if (u.getFragment() != null) {
        baseUri = baseUri + "#" + u.getFragment();
    }

    if (StringUtils.isNotBlank(baseUri)) {
        baseUri = host + baseUri;
    } else {
        baseUri = host;
    }

    return ImmutablePair.of(host, baseUri);
}
    
/**
 * uri 访问统计
 *
 * @param reqDTO
 * @return
 */
public SiteVisitDTO visit(VisitReqDTO reqDTO) {
    ImmutablePair<String, String> uri = URIUtil.foramtUri(reqDTO.getUri());

    // 获取站点的访问记录
    VisitVO uriVisit = doVisit(reqDTO.getApp(), uri.getRight(), reqDTO.getIp());
    VisitVO siteVisit;
    if (uri.getLeft().equals(uri.getRight())) {
        siteVisit = new VisitVO(uriVisit);
    } else {
        siteVisit = doVisit(reqDTO.getApp(), uri.getLeft(), reqDTO.getIp());
    }

    return new SiteVisitDTO(siteVisit, uriVisit);
}

private VisitVO doVisit(String app, String uri, String ip) {
    String pvKey = buildPvKey(app);
    String hotKey = buildHotKey(app);
    String uvKey = buildUvKey(app, uri);
    String todayVisitKey = buildUriTagKey(app, uri);

    Long hot = visitService.addHot(hotKey, uri);

    // 获取pv数据
    Long pv = visitService.getPv(pvKey, uri);
    if (pv == null || pv == 0) {
        // 历史没有访问过,则pv + 1, uv +1
        visitService.addPv(pvKey, uri);
        visitService.addUv(uvKey, ip, 1L);
        visitService.tagVisit(todayVisitKey, ip);
        return new VisitVO(1L, 1L, 1L, hot);
    }


    // 判断ip今天是否访问过
    boolean visit = visitService.visitToday(todayVisitKey, ip);

    // 获取uv及排名
    ImmutablePair</**uv*/Long, /**rank*/Long> uv = visitService.getUv(uvKey, ip);

    if (visit) {
        // 今天访问过,则不须要修改pv/uv;能够直接返回所需数据
        return new VisitVO(pv, uv.getLeft(), uv.getRight(), hot);
    }

    // 今天没访问过
    if (uv.left == 0L) {
        // 首次有人访问, pv + 1; uv +1
        visitService.addPv(pvKey, uri);
        visitService.addUv(uvKey, ip, 1L);
        visitService.tagVisit(todayVisitKey, ip);
        return new VisitVO(pv + 1, 1L, 1L, hot);
    } else if (uv.right == 0L) {
        // 这个ip首次访问, pv +1; uv + 1
        visitService.addPv(pvKey, uri);
        visitService.addUv(uvKey, ip, uv.left + 1);
        visitService.tagVisit(todayVisitKey, ip);
        return new VisitVO(pv + 1, uv.left + 1, uv.left + 1, hot);
    } else {
        // 这个ip的今天第一次访问, pv + 1 ; uv 不变
        visitService.addPv(pvKey, uri);
        visitService.tagVisit(todayVisitKey, ip);
        return new VisitVO(pv + 1, uv.left, uv.right, hot);
    }
}
复制代码

IV. 测试与小结

1. 测试

搭建一个简单的web服务,开始测试

/** * Created by @author yihui in 18:58 19/5/12. */
@Controller
public class VisitController {
    @Autowired
    private SiteVisitFacade siteVisitFacade;

    @RequestMapping(path = "visit")
    @ResponseBody
    public SiteVisitDTO visit(VisitReqDTO reqDTO) {
        return siteVisitFacade.visit(reqDTO);
    }
}
复制代码

a. 首次访问

# 首次访问,返回的全是1
http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/home
复制代码

test a

b. 再次访问

# 再次访问,由于一样是今天访问,除了hot为2;其余的都是1
http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/home
复制代码

test b

c. 同ip,不一样URI

# 同一ip,换个uri;除站点返回hot为3,其余的全是1
http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/index
复制代码

test c

d. 不一样ip,接上一个URI

# 换个ip,这个uri;主站点hot=4, pv,uv,rank=2; uriVO全是2
http://localhost:8080/visit?app=demo&ip=192.168.0.2&uri=http://hhui.top/index
复制代码

test d

e. 上一个ip,换第一个uri

# 换个ip,这个uri;主站点hot=5, pv,uv,rank=2; uriVO hot为3,其余全是2
http://localhost:8080/visit?app=demo&ip=192.168.0.2&uri=http://hhui.top/home
复制代码

test e

f. 次日访问

真要次日操做有点麻烦,为了验证,直接干掉今天的占位标记

rest

# 模拟次日访问, pv + 1, uv不变, hot+1
http://localhost:8080/visit?app=demo&ip=192.168.0.2&uri=http://hhui.top/home
复制代码

test f

2. 小结

本文能够说是redis学习以后,一个挺好的应用场景,涉及到了咱们经常使用和不经常使用的几个数据结构,包括hash,zset,bitmap, 其中关于bitmap的使用我的感受仍是很是有意思的;

对于redis操做不太熟的,能够参考下前面几篇博文

注意

上面这个服务,在实际使用中,须要考虑并发问题,很明显咱们上的设计并非多线程安全的,也就是说,在并发量大的时候,获取的数据极有可能和预期的不一致

扩展

上文的设计中,每一个uri都有一组位图,咱们能够经过遍历,获取value为1的下标,来统计这个页面今天的pv数,以及更相信的今天哪些ip访问过;一样也能够分析站点的今日UV数,以及对应的访问ip

0. 项目

1. 一灰灰的我的博客,记录全部学习和工做中的博文,欢迎你们前去逛逛

尽信书则不如,以上内容,纯属一家之言,因我的能力有限,不免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

QrCode
相关文章
相关标签/搜索