SpringBoot结合redis解决PV、UV亿级流量
文章目录
- SpringBoot结合redis解决PV、UV亿级流量
- 一 背景
- 1. 初级开发视角
- 2. 解决方案
- 二 上代码
- 1. 关系数据库表
- 2. 切面设计
- 3. 测试
- 4. 数据同步
- 三 交互
- 1. 知识详情交互
- 2. 转发知识交互
- 四 总结
一 背景
1. 初级开发视角
文章浏览量统计,最傻的做法就是:用户每次浏览,前端会发送一个GET
请求获取一篇文章详情时,会把这篇文章的浏览量+1
,存进数据库里。
分析存在的问题:
- 在GET请求的业务逻辑里进行了数据的写操作!
- 高并发,数据库压力太大,文章浏览量+1会存在线程不安全问题,加锁会很慢。
- 同时,如果文章做了缓存和搜索引擎如
ElasticSearch
的存储,同步更新缓存和ElasticSearch
更新同步更新太耗时,不更新就会导致数据不一致性。
2. 解决方案
HyperLogLog
HyperLogLog是Probabilistic data Structures的一种,这类数据结构的基本大的思路就是使用统计概率上的算法,牺牲数据的精准性来节省内存的占用空间及提升相关操作的性能。
设计思路
为保证真实的知识博文浏览量,根据用户访问的ip和文章id,进行唯一校验,即同一个用户多次访问同一篇文章,该文章访问量只增加1;
将用户的浏览量用opsForHyperLogLog().add(key,value)的存储在Redis中,在半夜浏览量低的时候,通过定时任务,将浏览量更新至数据库中。
核心点
日UV: key = 文章id, value = ip+当天时间 去重
独立UV: key = 文章id, value = ip去重
转发UV: key = 文章id-转发人id, value = ip去重
在不同命名空间下:
日登录UV: key = 文章id, value = 会员id+当天时间 去重
登录独立UV: key = 文章id, value = 会员id 去重
单体架构图
二 上代码
1. 关系数据库表
CREATE TABLE `kap_article` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`pv` bigint(20) DEFAULT '0' COMMENT '总浏览量',
`uv` bigint(20) DEFAULT '0' COMMENT '总访客量',
`zfl` bigint(20) DEFAULT '0' COMMENT '总转发量',
`name` varchar(120) NOT NULL COMMENT '知识名称',
`cover_img` varchar(500) NOT NULL COMMENT '知识封面图片',
`show_status` tinyint(1) DEFAULT '1' COMMENT '显示状态(1=显示,0=不显示)',
`content` text NOT NULL COMMENT '知识内容',
`forward_ranking` tinyint(1) DEFAULT '1' COMMENT '转发排行(1=开启,0=关闭)',
`ranking_show_num` bigint(20) DEFAULT '0' COMMENT '排行展示数(0=不限,n>0部分展示数从高到低)',
`classify_id` bigint(20) NOT NULL COMMENT '分类id',
`sort_by` int(11) DEFAULT '1' COMMENT '排序',
`tenant_id` bigint(20) NOT NULL COMMENT '租户id',
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除(1:是,0:否)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`create_user_name` varchar(50) DEFAULT NULL COMMENT '创建人',
`update_user_name` varchar(50) DEFAULT NULL COMMENT '更新人',
PRIMARY KEY (`id`),
UNIQUE KEY `index_name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COMMENT='知识表';
注:可以插入了一条数据,并设计访问量已经为10了,便于测试。
2. 切面设计
1. 自定义一个注解
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PageView {
/**
* 缓存命名空间前缀
*/
String prefix() default "";
}
- 切面处理
/**
* @author yanghouqing
* @date 2022-05-09
*/
@Aspect
@Configuration
@Slf4j
public class PageViewAspect {
/**
* 缓存命名空间
*/
final String MAYBELIKE_NAMESPACE = "vee_saas_mall:statistics:";
final String PV = "pv:";
final String UV = "uv:";
final String UV_DAY = "uv:day:";
@Autowired
private RedisUtils redisUtils;
/**
* 获取当前的ServletRequest
* @return
*/
protected HttpServletRequest servletRequest() {
return ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
}
/**
* 切入点
*/
@Pointcut("@annotation(cn.weiyisoft.platform.annotation.PageView)")
public void pageViewAspect() {}
/**
* 切入处理
* @param joinPoint
* @return
*/
@AfterReturning(value = "pageViewAspect() && @annotation(pageView)", returning = "result")
public void around(JoinPoint joinPoint, PageView pageView, Result result) {
log.info("@PageView请求返回内容:RESPONSE : {}", Objects.nonNull(result) ? result.toString() : "");
if (200 != result.getCode()) {
return;
}
// 先判断 pageView 是否为空, 为空则获取类上注解
if (pageView == null) {
Class<?> aClass = joinPoint.getTarget().getClass();
pageView = AnnotationUtils.findAnnotation(aClass, PageView.class);
}
Object[] object = joinPoint.getArgs();
Object id = object[0];
Object transmitCode = object[1];
log.info("id:{}", id);
log.info("transmitCode:{}", transmitCode);
this.cache(pageView,id);
// 判断是否携带转发code
if (ObjectUtils.isNotEmpty(transmitCode)) {
this.transmitCache(pageView,id,transmitCode);
}
}
/**
* 缓存 主维度
* @param pageView
* @param id
*/
private void cache(PageView pageView,Object id){
// 缓存 PV
redisUtils.incr(MAYBELIKE_NAMESPACE + pageView.prefix() + PV + id,1);
// 缓存 UV
redisUtils.add(MAYBELIKE_NAMESPACE + pageView.prefix() + UV + id, IpUtils.getIpAddr(servletRequest()));
// 缓存 UV 日访客
redisUtils.add(MAYBELIKE_NAMESPACE + pageView.prefix() + UV_DAY + id, IpUtils.getIpAddr(servletRequest())
+ "-" + DateUtil.format(new Date(), "yyyyMMdd"));
}
/**
* 缓存 转发人维度
* @param pageView
* @param id
* @param transmitCode
*/
private void transmitCache(PageView pageView,Object id,Object transmitCode){
// 缓存转发 PV
redisUtils.incr(MAYBELIKE_NAMESPACE + pageView.prefix() + PV + id +"-"+ ShareCodeUtil.codeToId(transmitCode.toString()),1);
// 缓存转发 UV
redisUtils.add(MAYBELIKE_NAMESPACE + pageView.prefix() + UV + id +"-"+ ShareCodeUtil.codeToId(transmitCode.toString()), IpUtils.getIpAddr(servletRequest()));
// 缓存转发 UV 日访客
redisUtils.add(MAYBELIKE_NAMESPACE + pageView.prefix() + UV_DAY + id +"-"+ ShareCodeUtil.codeToId(transmitCode.toString()), IpUtils.getIpAddr(servletRequest())
+ "-" + DateUtil.format(new Date(), "yyyyMMdd"));
}
}
- 工具类 RedisUtils
/**
* @author yanghouqing
* @date 2022-05-09
*/
@Component
public class RedisUtils {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 获取缓存
* @param key
*/
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
/**
* 批量获取缓存
* @param keys
*/
public List<Object> mGet(List<String> keys) {
return redisTemplate.opsForValue().multiGet(keys);
}
/**
* 删除缓存
* @param key 可以传一个值 或多个
*/
public void del(String... key) {
redisTemplate.delete(key[0]);
}
/**
* HyperLogLog计数
* @param key
* @param value
*/
public Long add(String key, Object... value) {
return redisTemplate.opsForHyperLogLog().add(key,value);
}
/**
* HyperLogLog获取总数
* @param key
*/
public Long count(String key) {
return redisTemplate.opsForHyperLogLog().size(key);
}
/**
* HyperLogLog获取总数
* @param key
*/
public Long count(String... key) {
return redisTemplate.opsForHyperLogLog().size(key);
}
/**
* 递增
* @param key 键
* @param delta 要增加几(大于0)
* @return
*/
public long incr(String key, long delta){
if(delta<0){
throw new RuntimeException("递增因子必须大于0");
}
return redisTemplate.opsForValue().increment(key, delta);
}
/**
* 递减
* @param key 键
* @param delta 要减少几(小于0)
* @return
*/
public long decr(String key, long delta){
if(delta<0){
throw new RuntimeException("递减因子必须大于0");
}
return redisTemplate.opsForValue().increment(key, -delta);
}
}
- 工具类 IpUtils
public class IpUtils {
//获取客户端IP地址
public static String getIpAddr(HttpServletRequest request) {
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknow".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
if (ip.equals("127.0.0.1")) {
//根据网卡取本机配置的IP
InetAddress inet = null;
try {
inet = InetAddress.getLocalHost();
} catch (Exception e) {
e.printStackTrace();
}
assert inet != null;
ip = inet.getHostAddress();
}
}
// 多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
if (ip != null && ip.length() > 15) {
if (ip.indexOf(",") > 0) {
ip = ip.substring(0, ip.indexOf(","));
}
}
return ip;
}
}
- 服务层列表高速查询逻辑 PageServices.java
@Autowired
private RedisUtils redisUtils;
@Transactional(readOnly = true)
public List<KapArticleDto> list(KapArticleQueryDto kapArticleQueryDto, PageParameter pageParameter) {
PageHelper.startPage(pageParameter.getPage(), pageParameter.getSize()).setOrderBy(ParameterUtil.getSort(pageParameter));
List<KapArticleDto> kapArticleDtos = this.kapArticleMapper.list(kapArticleQueryDto);
List<String> idsKeysPV = kapArticleDtos.stream().map(a -> MAYBELIKE_NAMESPACE_PV + a.getId()).collect(Collectors.toList());
List<Integer> pvInts = (List<Integer>)(List) redisUtils.mGet(idsKeysPV);
List<Long> objectsPV = pvInts.stream().map(a -> a == null ? 0L : a).collect(Collectors.toList());
AtomicInteger index = new AtomicInteger(0);
kapArticleDtos = kapArticleDtos.stream().map(a -> {
a.setPv(objectsPV.get(index.getAndIncrement()));
try {
a.setUv(redisUtils.count(MAYBELIKE_NAMESPACE_UV+a.getId()));
} catch (Exception e) {
log.error("批量获取文章UV失败跳过,文章:{},异常:{}",a,e);
}
return a;
}).collect(Collectors.toList());
return kapArticleDtos;
}
- 测试接口 PageController.java
@RestController
@Slf4j
public class PageController {
@Autowired
private KapArticleService kapArticleService;
@Autowired
private RedisUtils redisUtils;
@GetMapping(value = "/list")
@ApiOperation("查询知识")
public Result<PageInfo<KapArticleDto>> areaList(
@Validated PageParameter pageParameter,
@Validated KapArticleQueryDto kapArticleQueryDto
) {
PageInfo<KapArticleDto> areaPageInfo = new PageInfo<>(this.kapArticleService.list(kapArticleQueryDto, pageParameter));
return R.success(areaPageInfo);
}
@PageView(prefix = "kap:")
@GetMapping(value = "/info")
@ApiOperation("知识详情信息")
public Result<KapArticleDto> areaInfo(
@ApiParam(name="id", value = "id", required = true, example = "1") @NotNull(message = "id不允许为空") @RequestParam Long id,
@ApiParam(name="code", value = "转发code", example = "1") @RequestParam(required = false) String code
) {
KapArticle kapArticle = this.kapArticleService.findById(id);
if (null == kapArticle) {
return R.fail("文章不存在");
}
KapArticleDto kapArticleDto = KapArticleDto.convertFrom(kapArticle);
kapArticleDto.setPv((Long) redisUtils.get("vee_saas_mall:statistics:kap:pv:"+id));
kapArticleDto.setUv(redisUtils.count("vee_saas_mall:statistics:kap:uv:"+id));
return R.success(kapArticleDto);
}
}
3. 测试
redis入库数据截图:
接口数据返回截图:
4. 数据同步
数据同步指的是将redis中最新pvuv同步至mysql,包扣统计报表同步至elasticsearch
数据明细统计
- es结构
@Data
@ESMetaData(indexName = "kap_article_detail",number_of_shards = 3,number_of_replicas = 0,printLog = true)
public class KapArticleDetail implements Serializable {
/**
* id
*/
@ESID
private String id;
/**
* 日期
*/
@ESMapping(datatype = DataType.date_type)
private Date time;
/**
* 转发量
*/
@ESMapping(datatype = DataType.long_type)
private Long zfl;
/**
* 浏览量
*/
@ESMapping(datatype = DataType.long_type)
private Long pv;
/**
* 访客量
*/
@ESMapping(datatype = DataType.long_type)
private Long uv;
/**
* 文章id
*/
@ESMapping(datatype = DataType.long_type)
private Long articleId;
}
- 定时任务
/**
* 知识数据明细统计 每晚拉取redis中数据同步至es
*/
@Slf4j
@Component
@Transactional
public class KapArticleDetailTask {
final String MAYBELIKE_NAMESPACE_PV = "vee_saas_mall:statistics:kap:pv:";
final String MAYBELIKE_NAMESPACE_UV = "vee_saas_mall:statistics:kap:uv:day:";
@Autowired
private ElasticsearchTemplate<KapArticleDetail,String> elasticsearchTemplate;
@Autowired
private RedisUtils redisUtils;
@Autowired
private KapArticleService kapArticleService;
private void auto() throws Exception {
List<KapArticleDto> kapArticleDtos = kapArticleService.getAllAndZfl();
log.info("知识数据明细统计,定时任务开始执行>>>>>>,文章条目数:{}", kapArticleDtos.size());
Integer okCount = 0;
for (KapArticleDto kapArticleDto : kapArticleDtos) {
// 查询前天的的数据
KapArticleDetail kapArticleDetailQT = elasticsearchTemplate.getById(
kapArticleDto.getId() +"-"+ DateUtil.format(getCreateTime(-2),"yyyyMMdd"),
KapArticleDetail.class);
String idZT = kapArticleDto.getId() +"-"+ DateUtil.format(getCreateTime(-1),"yyyyMMdd");
// 判断昨天的数据是否已经保存过
boolean existsZT = elasticsearchTemplate.exists(idZT,KapArticleDetail.class);
if (!existsZT) {
KapArticleDetail kapArticleDetail = new KapArticleDetail();
try {
kapArticleDetail.setId(idZT);
kapArticleDetail.setArticleId(kapArticleDto.getId());
Object pvs = redisUtils.get(MAYBELIKE_NAMESPACE_PV + kapArticleDto.getId());
Long uvs = redisUtils.count(MAYBELIKE_NAMESPACE_UV + kapArticleDto.getId());
long pv = Long.parseLong(pvs == null ? "0": pvs.toString());
long uv = uvs == null ? 0L: uvs;
if (null == kapArticleDetailQT) {
kapArticleDetail.setZfl(kapArticleDto.getZfl());
kapArticleDetail.setPv(pv);
kapArticleDetail.setUv(uv);
} else {
kapArticleDetail.setZfl(Math.max(kapArticleDto.getZfl() - kapArticleDetailQT.getZfl(), 0L));
kapArticleDetail.setPv(Math.max(pv - kapArticleDetailQT.getPv(), 0L));
kapArticleDetail.setUv(Math.max(pv - kapArticleDetailQT.getUv(), 0L));
}
kapArticleDetail.setTime(getCreateTime(-1));
boolean saveResult = elasticsearchTemplate.save(kapArticleDetail);
if (saveResult) okCount +=1;
} catch (Exception e){
log.error("知识数据明细统计,插入es失败:{},异常信息:{}", new Gson().toJson(kapArticleDetail), e);
}
}
}
log.info("知识数据明细统计,定时任务执行完毕<<<<<<,文章总条目数:{},成功条目数:{}", kapArticleDtos.size(), okCount);
}
/**
* 获取当前时间前n天凌晨时间
* @return
*/
private Date getCreateTime(int n) {
Calendar cal = Calendar.getInstance();
cal.add(cal.DATE,n);
cal.set(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH), 0, 0, 0);
Date beginOfDate = cal.getTime();
return beginOfDate;
}
}
转发排行榜统计
- es结构
@Data
@ESMetaData(indexName = "kap_article_transmit",number_of_shards = 3,number_of_replicas = 0,printLog = true)
public class KapArticleTransmit implements Serializable {
/**
* id
*/
@ESID
private String id;
/**
* 创建时间
*/
@ESMapping(datatype = DataType.date_type)
private Date createTime;
/**
* 更新时间
*/
@ESMapping(datatype = DataType.date_type)
private Date updateTime;
/**
* 合伙人
*/
@ESMapping(datatype = DataType.keyword_type)
private String nickName;
/**
* 手机号
*/
@Sensitive(type = SensitiveTypeEnum.MOBILE_PHONE)
@ESMapping(datatype = DataType.keyword_type)
private String mobile;
/**
* 合伙人会员id
*/
@ESMapping(datatype = DataType.long_type)
private Long memberId;
/**
* 转发量
*/
@ESMapping(datatype = DataType.long_type)
private Long zfl;
/**
* 浏览量
*/
@ESMapping(datatype = DataType.long_type)
private Long pv;
/**
* 访客量
*/
@ESMapping(datatype = DataType.long_type)
private Long uv;
/**
* 文章id
*/
@ESMapping(datatype = DataType.long_type)
private Long articleId;
}
- 定时任务
/**
* 知识转发排行统计 每晚拉取redis中数据同步至es
*/
@Slf4j
@Component
@Transactional
public class KapArticleTransmitTask {
final String MAYBELIKE_NAMESPACE_PV = "vee_saas_mall:statistics:kap:pv:";
final String MAYBELIKE_NAMESPACE_UV = "vee_saas_mall:statistics:kap:uv:";
@Autowired
private ElasticsearchTemplate<KapArticleTransmit,String> elasticsearchTemplate;
@Autowired
private RedisUtils redisUtils;
@Autowired
private KapArticleService kapArticleService;
private void auto() throws Exception {
List<KapTransmitRankingDto> kapTransmitRankingDtos = kapArticleService.getAllAndZFPH();
log.info("知识转发排行统计,定时任务开始执行>>>>>>,文章合伙人转发条目数:{}", kapTransmitRankingDtos.size());
Integer saveCount = 0, updateCount = 0, expCount = 0;
for (KapTransmitRankingDto kapTransmitRankingDto : kapTransmitRankingDtos) {
String key = kapTransmitRankingDto.getArticleId() + "-" + kapTransmitRankingDto.getMemberId();
KapArticleTransmit kapArticleTransmit = new KapArticleTransmit();
kapArticleTransmit.setId(key);
kapArticleTransmit.setNickName(kapTransmitRankingDto.getNickName());
kapArticleTransmit.setMobile(kapTransmitRankingDto.getMobile());
kapArticleTransmit.setMemberId(kapTransmitRankingDto.getMemberId());
kapArticleTransmit.setZfl(kapTransmitRankingDto.getZfl());
Object pvs = redisUtils.get(MAYBELIKE_NAMESPACE_PV + key);
Long uvs = redisUtils.count(MAYBELIKE_NAMESPACE_UV + key);
long pv = Long.parseLong(pvs == null ? "0": pvs.toString());
long uv = uvs == null ? 0L: uvs;
kapArticleTransmit.setPv(pv);
kapArticleTransmit.setUv(uv);
kapArticleTransmit.setArticleId(kapTransmitRankingDto.getArticleId());
try {
// 查询是否存在,存在就更新,不存在就新增。
boolean exists = elasticsearchTemplate.exists(key, KapArticleTransmit.class);
if (exists) {
kapArticleTransmit.setUpdateTime(new Date());
boolean updateResult = elasticsearchTemplate.update(kapArticleTransmit);
if (updateResult) updateCount += 1;
} else {
kapArticleTransmit.setUpdateTime(new Date());
kapArticleTransmit.setCreateTime(new Date());
boolean saveResult = elasticsearchTemplate.save(kapArticleTransmit);
if (saveResult) saveCount += 1;
}
} catch (Exception e) {
expCount += 1;
log.error("知识数据明细统计,操作es失败:{},异常信息:{}", new Gson().toJson(kapArticleTransmit), e);
}
}
log.info("知识转发排行统计,定时任务执行完毕<<<<<<," +
"文章合伙人转发条目数:{},新增条目数:{},更新条目数:{},异常条目数:{}",
kapTransmitRankingDtos.size(), saveCount, updateCount, expCount);
}
}
三 交互
角色:合伙人,会员,游客
1. 知识详情交互
访客通过打开合伙人转发的链接进入知识页面,通过调用后端接口获取相关知识数据渲染,code入参字段重要,此时后端会根据转发code做响应的记录以及pv,uv等,如果点击预览提示访客注册,此时注册成功就会和合伙人做关联。
//获取知识
curl -X GET http://127.0.0.1:8903/api/kapArticle/info?id=5&code=XUHZD9
//Query参数
id = 知识id //必填参数
//head参数
code = 转发code //非必填参数,通过点击转发按钮生成的
//成功响应示例
{
"code": 200,
"msg": "SUCCESS", //返回文字描述
"data": {
"id": "4", //素材id
"name": "库存品知识", //知识名称
"coverImg": "a.jpg", //知识封面图片
"showStatus": true, //显示状态
"content": "2222222", //知识内容(富文本base64二进制数据)
"forwardRanking": true, //转发排行
"rankingShowNum": "0", //排行展示数(0=不限,n>0部分展示数从高到低)
"classifyId": "4", //分类id
"classifyName": "测试分类", //分类名称
"sortBy": 1, //排序
"tenantId": "10", //租户id
"createTime": "2022-05-07 17:12:03", //创建时间
"updateTime": "2022-05-10 10:51:33", //修改时间
"pv": "10", //浏览量
"uv": "1", //访客量
"zfl": "1" //转发量
} //返回数据
}
2. 转发知识交互
访客不可以点击转发按钮获得code,前端调用接口的时候记得判断是否有合伙人角色,后端也会进行二次验证。
//生成转发code接口
curl -X POST http://127.0.0.1:8903/api/kapTransmit/genTransmitCode?articleId=5&tenantId=1
//Query参数
articleId = 知识id
tenantId = 租户id
//成功响应示例
{
"code": 200,
"msg": "SUCCESS", //返回文字描述
"data": {
"code": "XUHZD9", //转发code
} //返回数据
}
四 总结
注意:redis开启RDB持久化机制
如果大佬您满意不妨给小弟点个赞呗