- 第01节:短链接统计模块功能分析
大家一起来聊一下短链接监控该怎么做,然后众所周知,短链接如果说只是用作跳转,其实有些大材小用,虽然说这是它的这种基本功能,但是更重要的是你在调整的过程当中,它带来的一些数据分析的一些过程,我在网上找的两个做这种分析做的比较好的,我觉得首先第一个的话小码链接,然后我们可以看到它是分几个曲线的,首先第一就是它的一个访问曲线,也就相当于说访问次数,也就是我们常说的pv,就相当于不管是任何人,不管一个人访问了多少次,但凡这个短链接跳转一次长链接它就是一次访问次数。、
访问人数的话,假如说有两个人访问了10次短链接,那么它的访问人数就是二,它是只根据相当于UV对吧?它根据用户数去访问次数进行统计,而不是一来说访问了多少次。然后IP的话这个也很容易解释某些假如我们的用户分别属于IP,下面,那么IP统计二相当于他又在用户又鼻子上面又再一次进行了精细化的1个统计,然后用的数据模型,然后他跟的日期下面是他某一天的 ppuv以及IP数,然后接下来的话是访问地区,然后地区的话它是分为中国地图和世界地图,相当于我们可以看,其实这几次这些链接都是我自己访问的,可以看到它都是在北京,然后如果切换世界里的话,它就是在中国。
然后这边是24小时分布,相当于你这些请求它是在什么时间访问的,就是什么小时对吧?假如说我是在晚上6点访问的,它这里面就展示的是6:00访问6次,然后高频IP就相当于是我们的什么对吧?我们的你访问短链接的时候,假如我在我电脑上访问,然后我当前的IP是多少,然后他都给你记下来,他只会取访问次数比较高的这些IP中分布的话,就相当于这个比较容易理解,我周天访问的时候他就记住天周六访问周六大概是这个样子,操作系统,然后就是MAC然后我们这边可能有windows ubuntu还有一些其他的然后防浏览器,比如说chrome,然后还有一些比如说像还有啥来着,对异地之一,然后就加起来,然后反馈类型的话,比如说我我去访问了一次这个链接,他的新访客其实是加一的,假如说我第二天或者其他时间以后,就相当于是老访客数量对吧?目前的话这个数据的统计我还不是特别清楚,后面需要好好思考一下该怎么做,不过应该问题不大。
然后访问网络比较容易理解WiFi对吧?移动数据,然后访问设备是电脑还是移动设备对吧?是手机还是电脑,然后访问来源也是直接访问的,还是说通过别人的网页就是点击跳转对吧等等,然后我也看了我们另外一个叫做爱短链大同小异,然后这个东西大致这些东西反正我个人觉得有点过于复杂,你像我举个例子,你像我们做这个链接,其实像这种防卫来源对吧?
然后我对这些数据做了一些分析,然后我觉得我们需要一些什么样的数据,首先第一个就是机房的数据pvuv以及我们的uip,然后地区的话,因为我在网上找了一些关于访问通过IP来计算归属地的这些API大部分说实话收费的如果说,你想不收费还做得比较好的,我在高德上找了一下他们的 IP做的比较好,但是有一个限制他只能国内用,所以说我们只做中国地图,我们不做世界地图,然后24小时分布这个比较好做,然后高频防IP和一周分布也比较好做,然后操作系统防浏览器反馈类型以及访问设备
- 第02节:短链接统计模块数据库表设计
CREATE TABLE `link`.`t_link_access_stats` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`gid` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '分组标识',
`full_short_url` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '完整短链接',
`date` date NULL DEFAULT NULL COMMENT '日期',
`pv` int NULL DEFAULT NULL COMMENT '访问量',
`uv` int NULL DEFAULT NULL COMMENT '独立访问数',
`uip` int NULL DEFAULT NULL COMMENT '独立IP数',
`hour` int NULL DEFAULT NULL COMMENT '小时',
`weekday` int NULL DEFAULT NULL COMMENT '星期',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime NULL DEFAULT NULL COMMENT '修改时间',
`del_flag` tinyint(1) NULL DEFAULT NULL COMMENT '删除标识:0 未删除 1 已删除',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
别忘了再添加唯一索引:ALTER TABLE t_link_access_stats ADD UNIQUE INDEX idx_unique_access_stats (full_short_url, gid, weekday, hour);
不建议使用当前字段组合 (full_short_url, gid, weekday, hour),建议调整为:ALTER TABLE t_link_access_stats
ADD UNIQUE INDEX idx_unique_access_stats (full_short_url, gid, `date`, hour);
这里如果要分表的话,分片键用full_short_url更好吗?还是用gid呢?可能用 full_short_url 更好些,如果变更了分组的话,修改分组信息统计监控记录太多了。在最后章节【性能优化&问题修复】里我把所有监控表的 gid 字段删掉了。
- 第03节:如何统计短链接PV访问
我没有添加索引,因此无需删除,直接执行以下代码即可
ALTER TABLE `link`.`t_link_access_stats`
ADD UNIQUE INDEX `idx_unique_access_stats` (`full_short_url`,`gid`,`date`,`hour`) USING BTREE;
先做一个就是技术访问以及24小时访问以及一周分布,其实这些都是和这些ppuv这些东西是在一起的,所以说会把它规划到一个模块里面;然后我们在访问 ppuv以及 IP的时候,我们第一步就是先实现一个简易版的,因为要跟前所以说我们应该先实现一个比较容易实现的版本,等到全部监控讲完,我们会对目前的现有产品进行重构,因为你会发现第一版实现的话它会非常的漏洞,虽然能实现功能,但是生产基本上不可用,而你想如果要提供一个SaaS版的海量访问的一个短链接的,一个就是跳转它的监控必然也是海量的数据以及海量的请求,所以说设计还是有些考虑的首先我们先看一下我们第一版怎么设计首先的话,我们在我们在这个里面创建了记录,上节课也讲了,它是按照小时和信息维度,然后再加上短链接是有维度的,也就相当于假如周一它有24条记录,周二有24条记录,大概是这个样子。然后他怎么去进行数据统计比较好的方法,比如说自增对吧?像Redis里面的那种自动increment的方式去做其实是比较好的,但是因为我们mysql没有increment命令,但是我们可以用另外一个东西去做如果说,我们某个记录它存在了,我们就给它进行更新,如果不存在就新增,其实还是蛮符合我们的要求的,比如说我们的这条短链接已经存在了,对吧?我们就把初始的值给它加进去,如果说不说错了,短信记录不存在,我们就把这些初始的值这些零它的实际访问量然后把它加进去,如果已经存在,那么就在它原有的基础上给它加入指定的数值大概样子,如果要实现这个功能的话,我们是需要在这里面加一个唯一索引的是语法的一个限制,我们先加一下给大家演示一下效果。
然后在这里,比如说我们的链接里面是有记录的,但是不是我们链条,这个时候我们把这条记录执行一下,是什么情况,新的逻辑就是修改GPU,IP都加数值,pv,uv以及uip都各自价格数值,现在我们看其实这里面的话它的短链接只有这样一条记录,其实是没有对它进行新增的,没有进行修改的。 这个时候我们现在已经有记录,我们执行我们刷新一下看是不是P一IP都已经变了对吧?基于这种场景我们应该知道怎么样去做了对吧? Ok接下来我们去写代码,首先我们在数据库层面给他去做自增,已经明白该怎么去做了,但是接下来还有个问题就是它的pvuv以及对应的IP该怎么去做好?我们第一节课就是先讲就是这些比较基础的,然后比如说访问次数,访问次数就是pv对吧?用户访问一次我就对这个短链接加一次数值就可以了,ok然后这个要在什么时候去给它进行做就是在短链接访问的时候,应该是在对一跳转这一步。
如果你添加了索引,请按照以下方式
非常感谢大家的指正,经排查索引确实加的有问题,已在数据库初始化文件中修复。另外,针对已有的数据库表,请大家执行以下语句: ALTER TABLE `link`.`t_link_access_stats` DROP INDEX `idx_unique_access_stats`, ADD UNIQUE INDEX `idx_unique_access_stats` (`full_short_url`,`gid`,`date`,`hour`) USING BTREE;
dao的entity新建DO 如下
package com.nageoffer.shortlink.project.dao.entiry; import com.baomidou.mybatisplus.annotation.TableName; import com.nageoffer.shortlink.project.common.database.BaseDO; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Date; /** * 短链接基础访问监控实体 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor @TableName("t_link_access_stats") public class LinkAccessStatsDO extends BaseDO { /** * id */ private Long id; /** * 完整短链接 */ private String fullShortUrl; /** * 分组标识 */ private String gid; /** * 日期 */ private Date date; /** * 独立访客数 */ private Integer uv; /** * 访问量 */ private Integer pv; /** * 独立ip数 */ private Integer uip; /** * 小时 */ private Integer hour; /** * 星期 */ private Integer weekday; }
1. PV(Page View)页面浏览量
定义:PV 代表页面浏览量(Page View),是指网站或应用中某个页面被用户访问的次数。每次用户加载页面,都会增加一个 PV,无论这个用户是否是同一个。
2. UV(Unique Visitor)独立访客数
定义:UV 代表独立访客数(Unique Visitor),是指在一定时间内,访问网站或应用的不同用户数量。每个用户在该时间段内无论访问多少次,都会被计算为一个 UV。
新建mapper如下LinkAccessStatsMapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.nageoffer.shortlink.project.dao.entiry.LinkAccessStatsDO; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Param; /** * 短链接基础访问监控持久层 */ public interface LinkAccessStatsMapper extends BaseMapper<LinkAccessStatsDO> { /** * 记录基础访问监控数据 */ @Insert("INSERT INTO t_link_access_stats (full_short_url, gid, date, pv, uv, uip, hour, weekday, create_time, update_time, del_flag) " + "VALUES( #{linkAccessStats.fullShortUrl}, #{linkAccessStats.gid}, #{linkAccessStats.date}, #{linkAccessStats.pv}, #{linkAccessStats.uv}, #{linkAccessStats.uip}, #{linkAccessStats.hour}, #{linkAccessStats.weekday}, NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE pv = pv + #{linkAccessStats.pv}, " + "uv = uv + #{linkAccessStats.uv}, " + " uip = uip + #{linkAccessStats.uip};") void shortLinkStats(@Param("linkAccessStats") LinkAccessStatsDO linkAccessStatsDO); }
开头放入 private final LinkAccessStatsMapper linkAccessStatsMapper;
private void shortLinkStats(String fullShortUrl, String gid, ServletRequest request, ServletResponse response) { try { if (StrUtil.isBlank(gid)) { LambdaQueryWrapper<ShortLinkGotoDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class) .eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl); ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(queryWrapper); gid = shortLinkGotoDO.getGid(); } int hour = DateUtil.hour(new Date(), true); Week week = DateUtil.dayOfWeekEnum(new Date()); int weekValue = week.getIso8601Value(); LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder() .pv(1) .uv(1) .uip(1) .hour(hour) .weekday(weekValue) .fullShortUrl(fullShortUrl) .gid(gid) .date(new Date()) .build(); linkAccessStatsMapper.shortLinkStats(linkAccessStatsDO); } catch (Throwable ex) { log.error("短链接访问量统计异常", ex); } }
这里的唯一索引是不是要添加‘日期’进入复合索引中,不然的话对于同一个链接,我这周一12点访问的不就加到上周一12点上面去了;后面我记得加了,可以看看
这里最好不要new很多Date() 提前new一个比较好,有极小概率,刚好跨0点创建,会有错误。创建时间就在insert语句的时候用now就可以,更新时间可以在ON DUPLICATE KEY UPDATE后面再加一个“update_time = NOW()”
java8的时间api自带第几小时第几天,就不用hutool的了,int hourOfDay = LocalTime.now().getHour(); int dayOfWeek = LocalDate.now().getDayOfWeek().getValue(); 而且把linkAccesStatsDO中的时间设置为LocalDate,然后给日期的时候直接LocalDate.now()给的就是当前日期
考虑多个用户访问同一个短链接的话,监控部分不用加锁
- 第04节:如何统计短链接UV访问
单个用户访问多次相同短链接,该短链接记录用户数为1次。
什么是cookie? Cookie和Session
LinkAccessStatsDO.java
import com.baomidou.mybatisplus.annotation.TableName;
import com.nageoffer.shortlink.project.common.database.BaseDO;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
/**
* 短链接基础访问监控实体
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_link_access_stats")
public class LinkAccessStatsDO extends BaseDO {
/**
* id
*/
private Long id;
/**
* 完整短链接
*/
private String fullShortUrl;
/**
* 分组标识
*/
private String gid;
/**
* 日期
*/
private Date date;
/**
* 访问量
*/
private Integer pv;
/**
* 独立访客数
*/
private Integer uv;
/**
* 独立ip数
*/
private Integer uip;
/**
* 小时
*/
private Integer hour;
/**
* 星期
*/
private Integer weekday;
}
\projectdao\mapper新建LinkAccessStatsMapper.java
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nageoffer.shortlink.project.dao.entity.LinkAccessStatsDO;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
/**
* 短链接基础访问监控持久层
*/
public interface LinkAccessStatsMapper extends BaseMapper<LinkAccessStatsDO> {
/**
* 记录基础访问监控数据
*/
@Insert("INSERT INTO t_link_access_stats (full_short_url, gid, date, pv, uv, uip, hour, weekday, create_time, update_time, del_flag) " +
"VALUES( #{linkAccessStats.fullShortUrl}, #{linkAccessStats.gid}, #{linkAccessStats.date}, #{linkAccessStats.pv}, #{linkAccessStats.uv}, #{linkAccessStats.uip}, #{linkAccessStats.hour}, #{linkAccessStats.weekday}, NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE pv = pv + #{linkAccessStats.pv}, " +
"uv = uv + #{linkAccessStats.uv}, " +
" uip = uip + #{linkAccessStats.uip};")
void shortLinkStats(@Param("linkAccessStats") LinkAccessStatsDO linkAccessStatsDO);
}
前面加个 private final LinkAccessStatsMapper linkAccessStatsMapper;
@SneakyThrows
@Override
public void restoreUrl(String shortUri, ServletRequest request, ServletResponse response) {
String serverName = request.getServerName();
String fullShortUrl = serverName + "/" + shortUri;
String originalLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
if (StrUtil.isNotBlank(originalLink)) {
shortLinkStats(fullShortUrl, null, request, response);
((HttpServletResponse) response).sendRedirect(originalLink);
return;
}
boolean contains = shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl);
if (!contains) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
String gotoIsNullShortLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl));
if (StrUtil.isNotBlank(gotoIsNullShortLink)) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
RLock lock = redissonClient.getLock(String.format(LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl));
lock.lock();
try {
originalLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
if (StrUtil.isNotBlank(originalLink)) {
shortLinkStats(fullShortUrl, null, request, response);
((HttpServletResponse) response).sendRedirect(originalLink);
return;
}
LambdaQueryWrapper<ShortLinkGotoDO> linkGotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(linkGotoQueryWrapper);
if (shortLinkGotoDO == null) {
stringRedisTemplate.opsForValue().set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl), "-", 30, TimeUnit.MINUTES);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, shortLinkGotoDO.getGid())
.eq(ShortLinkDO::getFullShortUrl, fullShortUrl)
.eq(ShortLinkDO::getDelFlag, 0)
.eq(ShortLinkDO::getEnableStatus, 0);
ShortLinkDO shortLinkDO = baseMapper.selectOne(queryWrapper);
//stringRedisTemplate.opsForValue().set(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl), shortLinkDO.getOriginUrl());
//if (shortLinkDO != null) {
//if (shortLinkDO.getValidDate() != null && shortLinkDO.getValidDate().before(new Date())) {
if (shortLinkDO == null || (shortLinkDO.getValidDate() != null && shortLinkDO.getValidDate().before(new Date()))) {
stringRedisTemplate.opsForValue().set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl), "-", 30, TimeUnit.MINUTES);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
stringRedisTemplate.opsForValue().set(
String.format(GOTO_SHORT_LINK_KEY, fullShortUrl),
shortLinkDO.getOriginUrl(),
LinkUtil.getLinkCacheValidTime(shortLinkDO.getValidDate()), TimeUnit.MILLISECONDS
);
shortLinkStats(fullShortUrl, shortLinkDO.getGid(), request, response);
((HttpServletResponse) response).sendRedirect(shortLinkDO.getOriginUrl());
} finally {
lock.unlock();
}
}
private void shortLinkStats(String fullShortUrl, String gid, ServletRequest request, ServletResponse response) {
try {
if (StrUtil.isBlank(gid)) {
LambdaQueryWrapper<ShortLinkGotoDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(queryWrapper);
gid = shortLinkGotoDO.getGid();
}
int hour = DateUtil.hour(new Date(), true);
Week week = DateUtil.dayOfWeekEnum(new Date());
int weekValue = week.getIso8601Value();
LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder()
.pv(1)
.uv(1)
.uip(1)
.hour(hour)
.weekday(weekValue)
.fullShortUrl(fullShortUrl)
.gid(gid)
.date(new Date())
.build();
linkAccessStatsMapper.shortLinkStats(linkAccessStatsDO);
} catch (Throwable ex) {
log.error("短链接访问量统计异常", ex);
}
}
feature:开发短链接监控之基础访问PV数据
如果说你要去给他他返回UV的话我们应该干什么?我们要定一个uuID。第二就是ID应该是叫 UV flag. 然后接下来我们要创建一个cookie的标识,UV cookie等于new一个cookie,用一个cookie的话我们就叫UV(cook也要给它设置一个标识时间;因为要有一个过期时间的set,max,然后这个是秒,然后再乘一个分钟,再乘以24就是一天,然后再乘以30,1个月,相当于 cookie存在于我们浏览一个月,如果说一个月之内你用户去访问我们的短链接,他就能标识同一个人,一个月之后,不管因为从我们短链接的角度上很难有用户对吧?一个月之后还访问如果说,你想把用户的标注时间拉长,直接把 cookie的时间设置长一点就可以了。)。
然后这样的话我们要去给response,然后我们UV我想想。然后UV cookie点set path。 Pass什么意思?因为我们都知道 cookie这些东西是跟域名那些东西挂钩的,如果说你不设置path的话,它给你整个域名全部就是它的作用,如果是你整个域名的话,我举个例子,如果说人家访问的是你的nageoffer下面的东西对吧?它全部都会认为已经登录的就是这个不是so URL下面对吧?你访问不同的端链接,人家都会以为你已经登录了,所以说我们要根据什么?我们要根据它的一个 path去做,去做一个标识,对。因为我们这边pass的话是要根据我们短域名的后面的标识去做的,这我们这边是个fullshorturl,所以说我们要给它进行一个切割,然后切割的话就是以短域名后面杠为标识的,fullShortUrl.indexOf("/"), fullShortUrl.length()),对。然后这样的话我们给response设置一个cookie,还得给他去做强转HTTP ((HttpServletResponse) response)
成功之后还要干什么? 客户端里面传的cookie不等于空,那么我们开始给它进行判断有没有我们的uVcookie。 stringRedisTemplate.opsForSet().add("short-link:stats:uv:" + fullShortUrl, uv);实现那么就是那个已经已经访问过了,set结构来+1.each、是那个结构标识
已经存在那么uv是不进行+1的
private void shortLinkStats(String fullShortUrl, String gid, ServletRequest request, ServletResponse response) { AtomicBoolean uvFirstFlag = new AtomicBoolean(); Cookie[] cookies = ((HttpServletRequest) request).getCookies(); try { Runnable addResponseCookieTask = () -> { String uv = UUID.fastUUID().toString(); Cookie uvCookie = new Cookie("uv", uv); uvCookie.setMaxAge(60 * 60 * 24 * 30); uvCookie.setPath(StrUtil.sub(fullShortUrl, fullShortUrl.indexOf("/"), fullShortUrl.length())); ((HttpServletResponse) response).addCookie(uvCookie); uvFirstFlag.set(Boolean.TRUE); stringRedisTemplate.opsForSet().add("short-link:stats:uv:" + fullShortUrl, uv); }; if (ArrayUtil.isNotEmpty(cookies)) { Arrays.stream(cookies) .filter(each -> Objects.equals(each.getName(), "uv")) .findFirst() .map(Cookie::getValue) .ifPresentOrElse(each -> { Long added = stringRedisTemplate.opsForSet().add("short-link:stats:uv:" + fullShortUrl, each); uvFirstFlag.set(added != null && added > 0L); }, addResponseCookieTask); } else { addResponseCookieTask.run(); } if (StrUtil.isBlank(gid)) { LambdaQueryWrapper<ShortLinkGotoDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class) .eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl); ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(queryWrapper); gid = shortLinkGotoDO.getGid(); } int hour = DateUtil.hour(new Date(), true); Week week = DateUtil.dayOfWeekEnum(new Date()); int weekValue = week.getIso8601Value(); LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder() .pv(1) //uv不再设置成1 .uv(uvFirstFlag.get() ? 1 : 0) .uip(1) .hour(hour) .weekday(weekValue) .fullShortUrl(fullShortUrl) .gid(gid) .date(new Date()) .build(); linkAccessStatsMapper.shortLinkStats(linkAccessStatsDO); } catch (Throwable ex) { log.error("短链接访问量统计异常", ex); } }
feature:开发短链接监控之基础访问uv数据
我们直接先创建一个短链接,结果在是nurl.ink/1fN5rc ,在浏览器访问一下
再访问一下
UV的一个列表对吧?存在里面你要存多长时间?如果说,千万亿的人去访问你的这些很多浏览器,包括很多的这些短链接,难道你每个链接都要维护这样的一个很大集合吗?肯定是不对;这样的话后续会在监控重构的时候fix
提示 统计方法要在重定向前调用,不然cookie加不进去
我觉得逻辑有点问题,如果名为uv的cookie存在,不应该用set的add命令来判断是否是新用户,因为add命令无法防止用户恶意修改cookie值;cookie判断的方式不太好处理这个问题,只能粗略地认为只要cookie存在就是老用户。
miccoui 回复 王小明:如果cookie存在,但是集合中查不到,不就是表示是伪造的吗,此时也可以认为是新用户吧?
王小明 回复 miccoui:问了下马哥这个问题没法解决...无法防止用户改cookie 不过应该也没有用户这么无聊就是了
2024-01-20 12:16
miccoui 回复 王小明:不过这样好像确实防不住恶意修改。
王小明 回复 miccoui:的确
全凭自觉了
2024-01-20 12:37
马丁 回复 王小明:很难防止篡改的,包括像小码短链接我也测试过,防不住
2024-04-15 13:23
TheTurtle 回复 王小明:还是那句话 没有银弹
这里再高并发的情况下,数据库能承受么?set能完成任务么?
后面用了消息队列进行削峰,肯定不会让数据库去抗并发高的请求。Set 完成功能是没问题的;使用hyperloglog呢?我觉得数据量大了肯定得用hyperloglog
cookie中有uv不是已经说明用户访问过了吗,为什么还要在redis里面判断
cookie只是标识你在这个短链接上面是一个老用户,但是老用户在不同的两天访问只加一次uv吗?显然不是吧,这里redis的set就需要设置过期时间到第二天凌晨。 那这样cookie和redis的作用不就清楚了。这个我想想,感觉可以改为如果没带uv就应该+1,如果携带了忽略
cookie.setPath("/shorturi")的作用是用户下次访问该短链接的时候会携带cookie,如果不设置的话默认是访问当前域名下的所有短链接都会携带cookie。不过好像并不影响,访问新链接的话因为redis中不存在该cookie依旧会认为是第一次访问,甚至还能少生成一次uuid。不知道对于访问新链接都使用一个新的cookie是否必要?
添加cookie的时候为什么要建一个runnable的线程呢?这是那个函数的用法,要求值为null时传递一个runnbale类型的参数
uvFirstFlag是一个AtomicBoolean类型的变量,它是线程安全的。AtomicBoolean提供了原子操作,可以确保多个线程之间的可见性和一致性。在addResponseCookieTask任务中,uvFirstFlag被设置为Boolean.TRUE,而在另一个线程中通过uvFirstFlag.get()来读取它的值。由于AtomicBoolean的特性,这个读取操作是安全的,不会受到其他线程的影响。因此,不会出现脏数据的问题。uvFirstFlag的值在多个线程之间是同步和可靠的。 对于Atomic我总结了一篇文章:Java并发编程:深入理解java.util.concurrent.atomic包 - Fivk博客
请问哪里有另一个线程通过uvFirstFlag.get()来读取它的值?并不是这样,和这个没关系,局部用boolean本就是线程安全的,这里用atomic是因为lambda表达式里面只能使用不能改变的变量(因为最终这个变量会被编译到匿名内部类的内部),用atomic他的引用是不会变的,只是改变里面的value,所以可行
- 第05节:如何统计短链接IP访问
通过httpservletRespose来获得
if (ArrayUtil.isNotEmpty(cookies)) { Arrays.stream(cookies) .filter(each -> Objects.equals(each.getName(), "uv")) .findFirst() .map(Cookie::getValue) .ifPresentOrElse(each -> { Long uvAdded = stringRedisTemplate.opsForSet().add("short-link:stats:uv:" + fullShortUrl, each); uvFirstFlag.set(uvAdded != null && uvAdded > 0L); }, addResponseCookieTask); } else { addResponseCookieTask.run(); } String remoteAddr = LinkUtil.getActualIp(((HttpServletRequest) request)); Long uipAdded = stringRedisTemplate.opsForSet().add("short-link:stats:uip:" + fullShortUrl, remoteAddr); boolean uipFirstFlag = uipAdded != null && uipAdded > 0L; if (StrUtil.isBlank(gid)) { LambdaQueryWrapper<ShortLinkGotoDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class) .eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl); ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(queryWrapper); gid = shortLinkGotoDO.getGid(); } int hour = DateUtil.hour(new Date(), true); Week week = DateUtil.dayOfWeekEnum(new Date()); int weekValue = week.getIso8601Value(); LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder() .pv(1) //uv不再设置成1 .uv(uvFirstFlag.get() ? 1 : 0) .uip(uipFirstFlag ? 1 : 0)
LinkUtil.java里面加个
/** * 获取用户真实IP * * @param request 请求 * @return 用户真实IP */ public static String getActualIp(HttpServletRequest request) { String ipAddress = request.getHeader("X-Forwarded-For"); if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { ipAddress = request.getHeader("Proxy-Client-IP"); } if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { ipAddress = request.getHeader("WL-Proxy-Client-IP"); } if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { ipAddress = request.getHeader("HTTP_CLIENT_IP"); } if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { ipAddress = request.getHeader("HTTP_X_FORWARDED_FOR"); } if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { ipAddress = request.getRemoteAddr(); } return ipAddress; }
tx:uv和ip这里最好把表的索引里的hour删掉,或者给redis设置过期时间,要不然跳小时后会新建记录并且由于redis存在缓存导致uv和ip都为0
2024-02-25 15:15
真滴很强 回复 tx:我写了个获取当前时间与下一个整点的秒数差值作为redis缓存的过期时间
2024-04-23 11:54
Jluuno 回复 tx:/** * 根据当前时间到下一个小时的分钟差值并返回 * @return */ public static int minutesUntilNextHour() { // 获取当前时间 LocalDateTime now = LocalDateTime.now(); // 获取下一个整点时间 LocalDateTime nextHour = now.plusHours(1).truncatedTo(ChronoUnit.HOURS); // 计算当前时间到下一个整点的分钟差值 long minutesUntilNextHour = ChronoUnit.MINUTES.between(now, nextHour); return (int) minutesUntilNextHour; }
2024-07-01 17:57
3 回复 tx:试了下确实有这个问题,设置个过期时间就可以 if (uipAdded != null && uipAdded > 0) { stringRedisTemplate.expire("short-link:stats:uv:" + fullShortUrl, 60, TimeUnit.SECONDS); }
2024-08-07 23:21
TheTurtle 回复 3:那后面计算某时间段内的总uv(总访问人数)就会多了啊 因为每个小时相同用户都重新计了一次 感觉设置的时间应该是当前时间到今日24:00:00的差值 大佬可以看看UV的定义:清楚易懂的讲解”UV和PV“的含义,以及之间的区别。_uv pv-CSDN博客
2024-08-11 13:21
TheTurtle 回复 真滴很强:感觉过期时间不应该是一个小时 不然后面计算某时间段内的总uv(总访问人数)就会多了 因为每个小时都会对相同用户都会重计1次UV 那么求和的时候肯定多了 本来每天每个用户计一次就够了。大佬可以看看UV的定义:https://blog.csdn.net/CJY131/article/details/10876...
2024-08-11 13:23
3 回复 TheTurtle:Okok我当时就是想着跑通就可以,没有细想
2024-08-15 14:05
小只小智 回复 真滴很强:这个方法秒啊
2024-10-19 17:15
Venom. 回复 3:但这个代码每次插一个新ip 缓存时间就重置了 跟我们想要的逻辑不一样吧 我们希望一开始设置一小时以后就不要重置了
2024-10-28 16:19
Binary_tre* 回复 tx:应该没事吧,uv和pv只记录一次就行啊,反正一个用户只记录一次,一个ip也只记录一次。如果在下一个小时有不同的ip和用户,redis中也查不到对应的Key-value,但是统计某个短链接的全局ip和uv数量不会有错的。那你设置过期时间岂不是每个小时这个短链接对应的一个用户都加了一次?
问题核心
原代码中,UV(基于Cookie)和Uip(基于IP)的Redis键未包含小时维度 (如 "short-link:stats:uv:" + fullShortUrl
),导致:
- 跨小时统计错误 :同一用户在不同小时访问时,因旧键未失效,新小时的UV/Uip会被错误标记为0(未新增)。
- 数据堆积风险 :Redis键无过期时间,长期存储导致内存膨胀。
解决方案
1. 按小时划分Redis键
- 修改键结构 :将小时信息纳入键名,例如: String uvKey = "short-link:stats:uv:" + fullShortUrl + ":" + hour; String uipKey = "short-link:stats:uip:" + fullShortUrl + ":" + hour;
- 作用 :确保每小时的UV/Uip统计独立,避免跨小时干扰。
2. 设置合理过期时间
- 按天过期 :若UV定义为“每日唯一访问”,则键过期时间设为当天24:00。 LocalDateTime now = LocalDateTime.now(); LocalDateTime midnight = now.with(LocalTime.MIDNIGHT).plusDays(1); long expireSeconds = Duration.between(now, midnight).getSeconds(); stringRedisTemplate.expire(uvKey, expireSeconds, TimeUnit.SECONDS);
- 按小时过期 :若UV定义为“每小时唯一访问”,则键过期时间设为当前小时结束。 // 示例:计算当前时间到下一小时的秒数差 LocalDateTime nextHour = now.plusHours(1).truncatedTo(ChronoUnit.HOURS); long expireSeconds = Duration.between(now, nextHour).getSeconds(); stringRedisTemplate.expire(uvKey, expireSeconds, TimeUnit.SECONDS);
3. 索引优化
- 若统计表(如
LinkAccessStatsDO
)按小时分组存储,需确保表索引包含hour
字段,避免全表扫描。
错误建议分析
- 设置60秒过期 (用户3):
可能导致同小时内多次访问被重复计UV,违背“唯一性”原则。 - 仅到下一整点 (真滴很强):
若UV需跨小时统计(如每日唯一),会导致数据提前过期,漏统计。 - 不设置过期 (Binary_tre*):
数据堆积和跨小时统计错误无法避免,不可取。
结论
TV的建议正确,但需根据UV/Uip的统计粒度(按小时/按天)调整键结构和过期时间。正确的做法是:
- 按统计粒度划分Redis键 (如
uv:短链:小时
或uv:短链:天
)。 - 设置过期时间为当前时间到统计粒度结束点 (如当天24:00或下一小时)。
- 同步优化数据库索引 ,确保查询效率。
- 第06节:如何统计短链接地区访问
通过网络IP获取用户所在地区。
获取地区 API
- 支持国内外IP地址
- 但是使用API限制较多
IP定位-API文档-开发指南-Web服务 API | 高德地图API
- 仅支持国内IP地址
- 使用API限制宽松
最终决定使用高德 IP API 定位接口。
创建高的服务的那个表
CREATE TABLE `t_link_locale_stats` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`full_short_url` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '完整短链接',
`gid` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '分组标识',
`date` date DEFAULT NULL COMMENT '日期',
`cnt` int DEFAULT NULL COMMENT '访问量',
`province` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '省份名称',
`city` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '市名称',
`adcode` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '城市编码',
`country` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '国家标识',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '修改时间',
`del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0表示删除 1表示未删除',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_unique_locale_stats` (`full_short_url`,`gid`,`date`,`adcode`,`province`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
在entiry中新建Do
package com.nageoffer.shortlink.project.dao.entiry;
import com.baomidou.mybatisplus.annotation.TableName;
import com.nageoffer.shortlink.project.common.database.BaseDO;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
/**
* 地区统计访问实体
*/
@Data
@TableName("t_link_locale_stats")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LinkLocaleStatsDO extends BaseDO {
/**
* id
*/
private Long id;
/**
* 完整短链接
*/
private String fullShortUrl;
/**
* 分组标识
*/
private String gid;
/**
* 日期
*/
private Date date;
/**
* 访问量
*/
private Integer cnt;
/**
* 省份名称
*/
private String province;
/**
* 市名称
*/
private String city;
/**
* 城市编码
*/
private String adcode;
/**
* 国家标识
*/
private String country;
}
创建LinkLocaleStatsMapper
package com.nageoffer.shortlink.project.dao.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.nageoffer.shortlink.project.dao.entiry.LinkLocaleStatsDO; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Param; /** * 地区统计访问持久层 */ public interface LinkLocaleStatsMapper extends BaseMapper<LinkLocaleStatsDO> { /** * 记录地区访问监控数据 */ @Insert("INSERT INTO t_link_locale_stats (full_short_url, gid, date, cnt, country, province, city, adcode, create_time, update_time, del_flag) " + "VALUES( #{linkLocaleStats.fullShortUrl}, #{linkLocaleStats.gid}, #{linkLocaleStats.date}, #{linkLocaleStats.cnt}, #{linkLocaleStats.country}, #{linkLocaleStats.province}, #{linkLocaleStats.city}, #{linkLocaleStats.adcode}, NOW(), NOW(), 0) " + "ON DUPLICATE KEY UPDATE cnt = cnt + #{linkLocaleStats.cnt};") void shortLinkLocaleState(@Param("linkLocaleStats") LinkLocaleStatsDO linkLocaleStatsDO); }
在Mapper里写sql可以使用JDK15新特性文本块,""" """的这个,会好看很多很
ShortLinkServicelmpl.java前面放个这个mapper ,这里的value导入到是import org.springframework.beans.factory.annotation.Value; 不是那个hutool的value
private final LinkLocaleStatsMapper linkLocaleStatsMapper;
@Value("${short-link.stats.locale.amap-key}")
private String statsLocaleAmapKey;
后方写方法如下
LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder() .pv(1) //uv不再设置成1 .uv(uvFirstFlag.get() ? 1 : 0) .uip(uipFirstFlag ? 1 : 0) .hour(hour) .weekday(weekValue) .fullShortUrl(fullShortUrl) .gid(gid) .date(new Date()) .build(); linkAccessStatsMapper.shortLinkStats(linkAccessStatsDO); Map<String, Object> localeParamMap = new HashMap<>(); localeParamMap.put("key", statsLocaleAmapKey); localeParamMap.put("ip", remoteAddr); String localeResultStr = HttpUtil.get(AMAP_REMOTE_URL, localeParamMap); JSONObject localeResultObj = JSON.parseObject(localeResultStr); String infoCode = localeResultObj.getString("infocode"); if (StrUtil.isNotBlank(infoCode) && StrUtil.equals(infoCode, "10000")) { String province = localeResultObj.getString("province"); boolean unknownFlag = StrUtil.equals(province, "[]"); LinkLocaleStatsDO linkLocaleStatsDO = LinkLocaleStatsDO.builder() .province(unknownFlag ? "未知" : province) .city(unknownFlag ? "未知" : localeResultObj.getString("city")) .adcode(unknownFlag ? "未知" : localeResultObj.getString("adcode")) .cnt(1) .fullShortUrl(fullShortUrl) .country("中国") .gid(gid) .date(new Date()) .build(); linkLocaleStatsMapper.shortLinkLocaleState(linkLocaleStatsDO); } } catch (Throwable ex) { log.error("短链接访问量统计异常", ex); } }
你打开我的应用 | 高德控制台,注册实名账号,然后选择web服务api,找到ip定位地址
下面的放在application.yaml
short-link: stats: locale: amap-key: 824c511f0997586ea016f979fdb23087 #非我本人
common里面导入高德地图的免费使用; 请自行申请的缺德地图的开发者api接口
package com.nageoffer.shortlink.project.common.constant; /** * 短链接常量类 */ public class ShortLinkConstant { /** * 永久短链接默认缓存有效时间 */ public static final long DEFAULT_CACHE_VALID_TIME = 2626560000L; /** * 高德获取地区接口地址 */ public static final String AMAP_REMOTE_URL = "https://restapi.amap.com/v3/ip"; }
- 第07节:如何统计短链接操作系统访问
CREATE TABLE `t_link_os_stats`
(
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`full_short_url` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '完整短链接',
`gid` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '分组标识',
`date` date DEFAULT NULL COMMENT '日期',
`cnt` int DEFAULT NULL COMMENT '访问量',
`os` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '操作系统',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '修改时间',
`del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0表示删除 1表示未删除',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_unique_locale_stats` (`full_short_url`, `gid`, `date`, `os`) USING BTREE
) COMMENT = '短链接监控操作系统访问状态'
ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
package com.nageoffer.shortlink.project.dao.entiry; import com.baomidou.mybatisplus.annotation.TableName; import com.nageoffer.shortlink.project.common.database.BaseDO; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Date; /** * 操作系统统计访问实体 */ @Data @TableName("t_link_os_stats") @Builder @NoArgsConstructor @AllArgsConstructor public class LinkOsStatsDO extends BaseDO { /** * id */ private Long id; /** * 完整短链接 */ private String fullShortUrl; /** * 分组标识 */ private String gid; /** * 日期 */ private Date date; /** * 访问量 */ private Integer cnt; /** * 操作系统 */ private String os; }
package com.nageoffer.shortlink.project.dao.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.nageoffer.shortlink.project.dao.entiry.LinkOsStatsDO; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Param; /** * 操作系统统计访问持久层 */ public interface LinkOsStatsMapper extends BaseMapper<LinkOsStatsDO> { /** * 记录地区访问监控数据 */ @Insert("INSERT INTO t_link_os_stats (full_short_url, gid, date, cnt, os, create_time, update_time, del_flag) " + "VALUES( #{linkOsStats.fullShortUrl}, #{linkOsStats.gid}, #{linkOsStats.date}, #{linkOsStats.cnt}, #{linkOsStats.os}, NOW(), NOW(), 0) " + "ON DUPLICATE KEY UPDATE cnt = cnt + #{linkOsStats.cnt};") void shortLinkOsState(@Param("linkOsStats") LinkOsStatsDO linkOsStatsDO); }
LinkUtil里面放这个
/** * 获取用户访问操作系统 * * @param request 请求 * @return 访问操作系统 */ public static String getOs(HttpServletRequest request) { String userAgent = request.getHeader("User-Agent"); if (userAgent.toLowerCase().contains("windows")) { return "Windows"; } else if (userAgent.toLowerCase().contains("mac")) { return "Mac OS"; } else if (userAgent.toLowerCase().contains("linux")) { return "Linux"; } else if (userAgent.toLowerCase().contains("android")) { return "Android"; } else if (userAgent.toLowerCase().contains("iphone") || userAgent.toLowerCase().contains("ipad")) { return "iOS"; } else { return "Unknown"; } }
LinkOsStatsDO linkOsStatsDO = LinkOsStatsDO.builder()
.os(LinkUtil.getOs(((HttpServletRequest) request)))
.cnt(1)
.gid(gid)
.fullShortUrl(fullShortUrl)
.date(new Date())
.build();
linkOsStatsMapper.shortLinkOsState(linkOsStatsDO);
}
} catch (Throwable ex) {
log.error("短链接访问量统计异常", ex);
}
}
switch case应该是性能更好,更优雅一点吧,不过刚问了gpt 说是: 虽然在理论上 switch-case 通常会比 if-else 性能稍高,但在实际应用中,这种差异通常是微乎其微的。JVM 的优化在现代编译器上已经非常有效,因此在大多数场景中,选择 switch-case 还是 if-else 更多是基于代码的可读性和可维护性,而不是纯粹的性能。
- 第08节:如何统计短链接浏览器访问
一些没用的小文件
windows,但是我用edge浏览器访问里面只有chrome和safari,没有edge
1.历史原因:在2008年,谷歌基于苹果的Webkit引l擎开发了Chrome浏览器,为了兼容那些专为
Safari编写的网页,Chrome的User-Agent继承了Safari的标识
2.兼容性:微软在开发Edge浏览器时,为了确保网页能在新浏览器上正确渲染,也采用了类似的
策略。Edge浏览器在基于Chromium内核开发后,其User-Agent字符串中包含了Chrome的标
识,这样做可以帮助Edge浏览器通过一些基于User-Agent进行浏览器检测的网站
还是通过这个request去拿这个user-agent的,这几张逻辑基本上一样的
CREATE TABLE `t_link_browser_stats` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
`gid` varchar(32) DEFAULT 'default' COMMENT '分组标识',
`date` date DEFAULT NULL COMMENT '日期',
`cnt` int(11) DEFAULT NULL COMMENT '访问量',
`browser` varchar(64) DEFAULT NULL COMMENT '浏览器',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_unique_browser_stats` (`full_short_url`,`gid`,`date`,`browser`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4;;
package com.nageoffer.shortlink.project.dao.entiry; import com.baomidou.mybatisplus.annotation.TableName; import com.nageoffer.shortlink.project.common.database.BaseDO; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Date; /** * 浏览器统计访问实体 */ @Data @TableName("t_link_browser_stats") @Builder @NoArgsConstructor @AllArgsConstructor public class LinkBrowserStatsDO extends BaseDO { /** * id */ private Long id; /** * 完整短链接 */ private String fullShortUrl; /** * 分组标识 */ private String gid; /** * 日期 */ private Date date; /** * 访问量 */ private Integer cnt; /** * 浏览器 */ private String browser; }
package com.nageoffer.shortlink.project.dao.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.nageoffer.shortlink.project.dao.entiry.LinkBrowserStatsDO; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Param; /** * 浏览器统计访问持久层 */ public interface LinkBrowserStatsMapper extends BaseMapper<LinkBrowserStatsDO> { /** * 记录浏览器访问监控数据 */ @Insert("INSERT INTO t_link_browser_stats (full_short_url, gid, date, cnt, browser, create_time, update_time, del_flag) " + "VALUES( #{linkBrowserStats.fullShortUrl}, #{linkBrowserStats.gid}, #{linkBrowserStats.date}, #{linkBrowserStats.cnt}, #{linkBrowserStats.browser}, NOW(), NOW(), 0) " + "ON DUPLICATE KEY UPDATE cnt = cnt + #{linkBrowserStats.cnt};") void shortLinkBrowserState(@Param("linkBrowserStats") LinkBrowserStatsDO linkBrowserStatsDO); }
/** * 获取用户访问浏览器 * * @param request 请求 * @return 访问浏览器 */ public static String getBrowser(HttpServletRequest request) { String userAgent = request.getHeader("User-Agent"); if (userAgent.toLowerCase().contains("edg")) { return "Microsoft Edge"; } else if (userAgent.toLowerCase().contains("chrome")) { return "Google Chrome"; } else if (userAgent.toLowerCase().contains("firefox")) { return "Mozilla Firefox"; } else if (userAgent.toLowerCase().contains("safari")) { return "Apple Safari"; } else if (userAgent.toLowerCase().contains("opera")) { return "Opera"; } else if (userAgent.toLowerCase().contains("msie") || userAgent.toLowerCase().contains("trident")) { return "Internet Explorer"; } else { return "Unknown"; } }
impl 前面记得加mapper
LinkBrowserStatsDO linkBrowserStatsDO = LinkBrowserStatsDO.builder()
.browser(LinkUtil.getBrowser(((HttpServletRequest) request)))
.cnt(1)
.gid(gid)
.fullShortUrl(fullShortUrl)
.date(new Date())
.build();
linkBrowserStatsMapper.shortLinkBrowserState(linkBrowserStatsDO);
public static UserAgent getUserAgent(HttpServletRequest request) { return UserAgentUtil.parse(request.getHeader("User-Agent")); } Hutool工具包 UserAgentUtil 可以获取UserAgent ,里面包含 操作系统与浏览器类型.
- 第09节:如何统计短链接高频IP访问
mysql 做这个事情,如果说你要在mysql 里面做高频的访问IP,那么一定要记录它的操作:访问日志,咱们之前其实记过一个关于访问的这些信息,比如说我们的独立IP以及访问量以及 UV对吧?我们是统计的是汇总对吧?其实是没有单个IP的,相当于我们现在就不搞这个访问统计了,我们要搞访问日志表,日志表里面都会包含什么呢?它会包含你这个请求的用户对应的浏览器IP,以及一个是否电脑操作,还有以及一些其他的,操作系统,然后浏览器,然后这两个我们不要,然后把这几条IP记录下来,其实就能够去做我们的一个高频访IP的流程,把这些记录下来怎么访问怎么去操作高频访问IP,我们只需要给它进行一个group,然后给它进行一个count,把它最终的数据就是排名前几的数据反馈给我们就好了,这就是高频繁IP。
CREATE TABLE `t_link_access_logs` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`full_short_url` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '完整短链接',
`gid` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '分组标识',
`user` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户信息',
`browser` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '浏览器',
`os` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '操作系统',
`ip` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'IP',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
package com.nageoffer.shortlink.project.dao.entiry; import com.baomidou.mybatisplus.annotation.TableName; import com.nageoffer.shortlink.project.common.database.BaseDO; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * 访问日志监控实体 */ @Data @TableName("t_link_access_logs") @Builder @NoArgsConstructor @AllArgsConstructor public class LinkAccessLogsDO extends BaseDO { /** * id */ private Long id; /** * 完整短链接 */ private String fullShortUrl; /** * 分组标识 */ private String gid; /** * 用户信息 */ private String user; /** * 浏览器 */ private String browser; /** * 操作系统 */ private String os; /** * ip */ private String ip; }
package com.nageoffer.shortlink.project.dao.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.nageoffer.shortlink.project.dao.entiry.LinkAccessLogsDO; /** * 访问日志监控持久层 */ public interface LinkAccessLogsMapper extends BaseMapper<LinkAccessLogsDO> { }
接下来的impl
private void shortLinkStats(String fullShortUrl, String gid, ServletRequest request, ServletResponse response) {
AtomicBoolean uvFirstFlag = new AtomicBoolean();
Cookie[] cookies = ((HttpServletRequest) request).getCookies();
try {
AtomicReference<String> uv = new AtomicReference<>();
Runnable addResponseCookieTask = () -> {
uv.set(UUID.fastUUID().toString());
Cookie uvCookie = new Cookie("uv", uv.get());
uvCookie.setMaxAge(60 * 60 * 24 * 30);
uvCookie.setPath(StrUtil.sub(fullShortUrl, fullShortUrl.indexOf("/"), fullShortUrl.length()));
((HttpServletResponse) response).addCookie(uvCookie);
uvFirstFlag.set(Boolean.TRUE);
stringRedisTemplate.opsForSet().add("short-link:stats:uv:" + fullShortUrl, uv.get());
};
if (ArrayUtil.isNotEmpty(cookies)) {
Arrays.stream(cookies)
.filter(each -> Objects.equals(each.getName(), "uv"))
.findFirst()
.map(Cookie::getValue)
.ifPresentOrElse(each -> {
uv.set(each);
Long uvAdded = stringRedisTemplate.opsForSet().add("short-link:stats:uv:" + fullShortUrl, each);
uvFirstFlag.set(uvAdded != null && uvAdded > 0L);
}, addResponseCookieTask);
} else {
addResponseCookieTask.run();
}
String remoteAddr = LinkUtil.getActualIp(((HttpServletRequest) request));
Long uipAdded = stringRedisTemplate.opsForSet().add("short-link:stats:uip:" + fullShortUrl, remoteAddr);
boolean uipFirstFlag = uipAdded != null && uipAdded > 0L;
if (StrUtil.isBlank(gid)) {
LambdaQueryWrapper<ShortLinkGotoDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(queryWrapper);
gid = shortLinkGotoDO.getGid();
}
int hour = DateUtil.hour(new Date(), true);
Week week = DateUtil.dayOfWeekEnum(new Date());
int weekValue = week.getIso8601Value();
LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder()
.pv(1)
//uv不再设置成1
.uv(uvFirstFlag.get() ? 1 : 0)
.uip(uipFirstFlag ? 1 : 0)
.hour(hour)
.weekday(weekValue)
.fullShortUrl(fullShortUrl)
.gid(gid)
.date(new Date())
.build();
linkAccessStatsMapper.shortLinkStats(linkAccessStatsDO);
Map<String, Object> localeParamMap = new HashMap<>();
localeParamMap.put("key", statsLocaleAmapKey);
localeParamMap.put("ip", remoteAddr);
String localeResultStr = HttpUtil.get(AMAP_REMOTE_URL, localeParamMap);
JSONObject localeResultObj = JSON.parseObject(localeResultStr);
String infoCode = localeResultObj.getString("infocode");
if (StrUtil.isNotBlank(infoCode) && StrUtil.equals(infoCode, "10000")) {
String province = localeResultObj.getString("province");
boolean unknownFlag = StrUtil.equals(province, "[]");
LinkLocaleStatsDO linkLocaleStatsDO = LinkLocaleStatsDO.builder()
.province(unknownFlag ? "未知" : province)
.city(unknownFlag ? "未知" : localeResultObj.getString("city"))
.adcode(unknownFlag ? "未知" : localeResultObj.getString("adcode"))
.cnt(1)
.fullShortUrl(fullShortUrl)
.country("中国")
.gid(gid)
.date(new Date())
.build();
linkLocaleStatsMapper.shortLinkLocaleState(linkLocaleStatsDO);
String os = LinkUtil.getOs(((HttpServletRequest) request));
LinkOsStatsDO linkOsStatsDO = LinkOsStatsDO.builder()
.os(os)
.cnt(1)
.gid(gid)
.fullShortUrl(fullShortUrl)
.date(new Date())
.build();
linkOsStatsMapper.shortLinkOsState(linkOsStatsDO);
String browser = LinkUtil.getBrowser(((HttpServletRequest) request));
LinkBrowserStatsDO linkBrowserStatsDO = LinkBrowserStatsDO.builder()
.browser(browser)
.cnt(1)
.gid(gid)
.fullShortUrl(fullShortUrl)
.date(new Date())
.build();
LinkAccessLogsDO linkAccessLogsDO = LinkAccessLogsDO.builder()
.user(uv.get())
.ip(remoteAddr)
.browser(browser)
.os(os)
.gid(gid)
.fullShortUrl(fullShortUrl)
.build();
linkAccessLogsMapper.insert(linkAccessLogsDO);
linkBrowserStatsMapper.shortLinkBrowserState(linkBrowserStatsDO);
}
} catch (Throwable ex) {
log.error("短链接访问量统计异常", ex);
}
}
这里每有一个短链接被访问就会往数据库插一条数据不会很占空间嘛,为什么不像之前的表一样搞个cnt来统计,是因为这张表会定期清理超过一定时间的数据嘛,只有近期的数据比较有价值嘛
- 第10节:如何统计短链接访客类型访问
当选择一个特定的时间段时,“新访客”指的是在该时间段内访问过短链接的人群中,有多少人是首次访问者。相对应地,“老访客”指的是在该时间段内访问过短链接的人群中,已经在之前访问过的人数。
可以选中当天的日期然后查看老访客的数量,如果老访客数量较多,就说明您投放的短链接很受欢迎,您的用户会持续来访问。
- 第11节:如何统计短链接访问设备访问
CREATE TABLE `t_link_device_stats` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
`gid` varchar(32) DEFAULT 'default' COMMENT '分组标识',
`date` date DEFAULT NULL COMMENT '日期',
`cnt` int(11) DEFAULT NULL COMMENT '访问量',
`device` varchar(64) DEFAULT NULL COMMENT '访问设备',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_unique_browser_stats` (`full_short_url`,`gid`,`date`,`device`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;;
package com.nageoffer.shortlink.project.dao.entiry; import com.baomidou.mybatisplus.annotation.TableName; import com.nageoffer.shortlink.project.common.database.BaseDO; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Date; /** * 访问设备统计访问实体 */ @Data @TableName("t_link_device_stats") @Builder @NoArgsConstructor @AllArgsConstructor public class LinkDeviceStatsDO extends BaseDO { /** * id */ private Long id; /** * 完整短链接 */ private String fullShortUrl; /** * 分组标识 */ private String gid; /** * 日期 */ private Date date; /** * 访问量 */ private Integer cnt; /** * 浏览器 */ private String device; }
/**
* 获取用户访问设备
*
* @param request 请求
* @return 访问设备
*/
public static String getDevice(HttpServletRequest request) {
String userAgent = request.getHeader("User-Agent");
if (userAgent.toLowerCase().contains("mobile")) {
return "Mobile";
}
return "PC";
}
//实际上是想看看有没有ipad平板类的
LinkDeviceStatsDO linkDeviceStatsDO = LinkDeviceStatsDO.builder()
.device(LinkUtil.getDevice(((HttpServletRequest) request)))
.cnt(1)
.gid(gid)
.fullShortUrl(fullShortUrl)
.date(new Date())
.build();
linkDeviceStatsMapper.shortLinkDeviceState(linkDeviceStatsDO);
package com.nageoffer.shortlink.project.dao.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.nageoffer.shortlink.project.dao.entiry.LinkDeviceStatsDO; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Param; /** * 访问设备监控持久层 */ public interface LinkDeviceStatsMapper extends BaseMapper<LinkDeviceStatsDO> { /** * 记录访问设备监控数据 */ @Insert("INSERT INTO t_link_device_stats (full_short_url, gid, date, cnt, device, create_time, update_time, del_flag) " + "VALUES( #{linkDeviceStats.fullShortUrl}, #{linkDeviceStats.gid}, #{linkDeviceStats.date}, #{linkDeviceStats.cnt}, #{linkDeviceStats.device}, NOW(), NOW(), 0) " + "ON DUPLICATE KEY UPDATE cnt = cnt + #{linkDeviceStats.cnt};") void shortLinkDeviceState(@Param("linkDeviceStats") LinkDeviceStatsDO linkDeviceStatsDO); }
- 第12节:如何统计短链接访问网络访问
CREATE TABLE `t_link_network_stats` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
`gid` varchar(32) DEFAULT 'default' COMMENT '分组标识',
`date` date DEFAULT NULL COMMENT '日期',
`cnt` int(11) DEFAULT NULL COMMENT '访问量',
`network` varchar(64) DEFAULT NULL COMMENT '访问网络',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_unique_browser_stats` (`full_short_url`,`gid`,`date`,`network`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;;
package com.nageoffer.shortlink.project.dao.entiry; import com.baomidou.mybatisplus.annotation.TableName; import com.nageoffer.shortlink.project.common.database.BaseDO; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Date; /** * 访问网络统计访问实体 */ @Data @TableName("t_link_network_stats") @Builder @NoArgsConstructor @AllArgsConstructor public class LinkNetworkStatsDO extends BaseDO { /** * id */ private Long id; /** * 完整短链接 */ private String fullShortUrl; /** * 分组标识 */ private String gid; /** * 日期 */ private Date date; /** * 访问量 */ private Integer cnt; /** * 访问网络 */ private String network; }
package com.nageoffer.shortlink.project.dao.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.nageoffer.shortlink.project.dao.entiry.LinkNetworkStatsDO; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Param; /** * 访问网络监控持久层 */ public interface LinkNetworkStatsMapper extends BaseMapper<LinkNetworkStatsDO> { /** * 记录访问设备监控数据 */ @Insert("INSERT INTO t_link_network_stats (full_short_url, gid, date, cnt, network, create_time, update_time, del_flag) " + "VALUES( #{linkNetworkStats.fullShortUrl}, #{linkNetworkStats.gid}, #{linkNetworkStats.date}, #{linkNetworkStats.cnt}, #{linkNetworkStats.network}, NOW(), NOW(), 0) " + "ON DUPLICATE KEY UPDATE cnt = cnt + #{linkNetworkStats.cnt};") void shortLinkNetworkState(@Param("linkNetworkStats") LinkNetworkStatsDO linkNetworkStatsDO); }
/** * 获取用户访问网络 * * @param request 请求 * @return 访问设备 */ public static String getNetwork(HttpServletRequest request) { String actualIp = getActualIp(request); // 这里简单判断IP地址范围,您可能需要更复杂的逻辑 // 例如,通过调用IP地址库或调用第三方服务来判断网络类型 return actualIp.startsWith("192.168.") || actualIp.startsWith("10.") ? "WIFI" : "Mobile"; }
LinkNetworkStatsDO linkNetworkStatsDO = LinkNetworkStatsDO.builder() .network(LinkUtil.getNetwork(((HttpServletRequest) request))) .cnt(1) .gid(gid) .fullShortUrl(fullShortUrl) .date(new Date()) .build(); linkNetworkStatsMapper.shortLinkNetworkState(linkNetworkStatsDO);
本地localhost是127.0.0.1,不满足getNetwork()的返回条件,所有才返回的"Mobile"
Comments NOTHING