引言
redis就不过多介绍了,我们常用它来做缓存;但是初学redis后对它的一些应用可能不是特别了解;今天看到书中的一个案例,就是我们常见的访问量(文章访问量、用户访问量、视频访问量…);结合之前学过的分布式锁和定时任务简单模拟了一下,简单记个笔记;
reids缓存实现访问量
下面演示一下用redis实现访问量的增加;
基本环境配置
数据库user表:visit是访问量;
项目环境:
- springboot2.3.7+java8+mysql8+mybatis-plus
先初始化好了项目,然后在controller层创建一个接口:/user/info/{id},我使用postman访问该接口,模拟访问某个用户:
接口实现逻辑如下:
逻辑很简单,就是通过id查数据库对应用户,然后该用户访问量+1,更新数据库中该用户的访问量即可;
为什么使用redis缓存访问量
上面的方法确实很简单,但是存在问题:假设某个用户每天的访问量特别大怎么办?
访问量这个字段是一种经常更新的字段,只要访问一个用户就要执行一次更新操作,访问一次写一次数据库,一旦用户量很多并且存多个热点用户的情况下,数据库的写操作量就非常大;(或许一个用户你想象不到,假设是b站那种百万播放的视频,访问量超百万,那么真的就是操作数据库百万次吗?)
所以我们不能每访问一次就写一次数据库,而是可以通过redis缓存来实现访问量的增加;
总的来说,就是访问量的存储操作是一个读写频繁的操作,不能将频繁的读写操作由数据库实现,应该使用缓存来操作;
具体实现逻辑
具体操作就是:每当访问一个用户时,如果redis中没有该用户访问量(visit)则将该用户当前访问量+1存到redis中,如果有则更新redis缓存让访问量+1;
代码如下:
注:redisCacheUtil是我自己封装的redisTemplate工具类,进一步简化redis读写操作,按正常的redisTemplate.xxx.xxx看就行了(后面代码再出现就不解释了)
@GetMapping("/info/{id}")
public User getUserInfoById(@PathVariable Long id) {
// 查询数据库中用户信息
User user = userService.getById(id);
// 访问该用户一次访问量增加一次
Integer visit = redisCacheUtil.getCacheObject("visit:" + id); // 从缓存中获取该用户访问量
// 判断redis是否有该用户访问量缓存
if (visit == null) { // 如果没有则新建redis缓存并将该次访问量存入缓存(该用户访问量+1)
visit = user.getVisit() + 1;
} else { // 更新缓存(访问+1)
visit += 1;
}
// 新建/更新缓存(1天超时)
redisCacheUtil.setCacheObject("visit:" + user.getId(), visit, 1, TimeUnit.DAYS);
user.setVisit(visit); // 更新一下返回信息
return user;
}
逻辑也不难,无非就是把mysql的操作移到了redis中;可以访问一下各个数据,查看redis的缓存情况:
可以看到访问的数据访问量都存入了redis中,每次访问对应数据都会增加;
但是这样还存留着一个问题:redis中的数据如何存到数据库中呢?什么时候合适呢?
这就需要用到定时任务了;
定时任务将redis中的访问量更新到数据库
springboot实现定时任务非常简单,只需要两步:
- 主启动类添加注解开启定时任务 @EnableScheduling
- 给要定时执行的方法添加 @Scheduling 注解,指定 cron 表达式确定执行频率
于是就可以编写一个定时执行的方法,每天固定时间把redis中的用户访问数据更新到数据库中;
实现很简单:
/**
* 将所有用户的访问数据存入数据库(每天凌晨两点执行)
*/
@Scheduled(cron = "0 0 2 * * ? ")
public void doCacheVisit() {
log.info("定时任务1=>开始向数据库储存访问数据");
// 获取所有缓存访问量的key
Collection<String> keys = redisCacheUtil.keys("visit:*");
// 遍历key对每个用户的数据库数据进行更新操作
keys.forEach(key -> {
Integer visit = redisCacheUtil.getCacheObject(key); // 访问量
Long id = Long.parseLong(key.replaceAll("visit:", "")); // 该用户id
// 更新数据库
User user = new User();
user.setId(id);
user.setVisit(visit);
userService.updateById(user);
});
log.info("定时任务1=>访问数据储存完成");
}
这样它就会每天晚上用户量比较少的时候执行定时任务,自动把数据更新了;
这里@Scheduled(cron = "0 0 2 * * ? ")注解中写的表达式叫做cron表达式,用来设置定时任务执行时间和周期的,不用学怎么写,网上有现成的工具生成就行,我用的下面这个:传送门
下面测试一下看看(定时任务设置时间为cron = "0 0,17 23 * * ? ",即23:17分执行,这是为了方便我测试自己随便定的时间):
当到了今天9月1日23:17时,控制台输出:
然后就是数据库更新操作日志
执行完成,查看数据库就可以看到数据库的数据都已经更新完成了;
这样整个流程就可以说是结束了,大致总结就是:访问用户->新增/更新缓存(访问量)->到点了执行定时任务更新数据库;
下面是分布式存在的一些问题;(单体项目可以略过了)
分布式情况下的问题
如果这一个服务部署到了多台服务器上,多个服务在同时运行,那么就会出现一个问题:
定时任务是固定时间执行的,那就意味着多个服务会同时执行定时任务,这样可能不会有太严重的bug出现,但是假设有100台服务器上的服务同时执行,明明只需要一个服务就能解决的结果那麽多个服务同时做相同的事情,那么这就是对资源的浪费;
并且还有可能会出现脏数据,比如数据的重复插入什么的;
那么就需要找一个办法实现多台服务器的定时任务执行时只有一台能执行;
有以下几种方法:
- 将定时任务抽取出来,单独作为一个服务部署到服务器上,定时任务就只在这台服务器上运行;(这样做需要多设置一台服务器,成本问题)
- 写个配置,每个服务器都执行定时任务,但是只有 ip 符合配置的服务器才真实执行业务逻辑,其他的直接返回;成本较低,但是写死配置ip可能会改变;那么就可以动态配置,根据实际服务器ip配置;(但是服务多了后IP配置很麻烦,需要人工修改)
- 分布式锁,多台服务器执行定时任务时同时抢一把锁,抢到锁的服务器执行定时任务逻辑;(开发成本增加,但是不管多少台服务器都不用配置)
可以通过redisson非常容易的实现分布式锁,这里不过多介绍redisson,可以看官方使用文档:传送门
redisson实现分布式锁
通过redisson创建一个锁,多个服务同时来抢这个锁,谁抢到了谁执行,执行完了释放锁;逻辑就是这么简单,看看代码:
@Scheduled(cron = "0 0,25 9 * * ? ")
public void doCacheVisit() {
log.info("定时任务1=>开始向数据库储存访问数据");
RLock lock = redissonClient.getLock("bode:scheduleTask:updateVisit:lock"); // 设置锁
try {
// 只有一个线程能获取到锁
if (lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
log.info("getLock: " + Thread.currentThread().getId());
// 下面就是定时任务逻辑(和上面代码一样)
// 获取所有缓存访问量的key
Collection<String> keys = redisCacheUtil.keys("visit:*");
// 遍历key对每个用户的数据库数据进行更新操作
keys.forEach(key -> {
Integer visit = redisCacheUtil.getCacheObject(key); // 访问量
Long id = Long.parseLong(key.replaceAll("visit:", "")); // 该用户id
// 更新数据库
User user = new User();
user.setId(id);
user.setVisit(visit);
userService.updateById(user);
});
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 只能释放自己的锁
if (lock.isHeldByCurrentThread()) {
log.info("unLock: " + Thread.currentThread().getId());
lock.unlock();
}
}
log.info("定时任务1=>访问数据储存完成");
}
因为redisson让代码看起来非常简单,就是创建了一个锁然后加了try…catch操作,和java多线程中的锁使用流程没啥区别;
到这如果同时运行多个服务那么就不会出现都执行定时任务的情况了,可以测试一下;(因为现在时间是9月2日09:20,所以我定时任务时间改位09:25)
多开两个服务出来,现在是三个服务在执行:
当到了9月2日09:25时,定时任务执行,可以看看三个服务的控制台输出:
8080服务:
8080服务获取到了锁,下面就执行了数据库更新操作,打印了相应日志
到最后又释放了锁,执行结束;
8081服务:
可以看到8081服务虽然进入了定时任务的执行,但是由于没有获取到锁,所以无法执行定时任务中的逻辑代码;直接结束了
8082服务:
8082服务和8081一样,就不多说了:
分布式锁有很多问题,这里没有过多介绍,感兴趣可以查查资料;(redisson也一样)可以看看我的这篇文章:关于分布式锁的个人理解
拓展:缓存高访问量的用户(热点数据)
访问量体现了一个数据的火热程度,那么就意味着热门数据会有很多用户来访问,那么如果每次访问还是查数据库的话,当访问的人多了就会给数据库造成压力,所以我们也可以找个固定时间把一段时间内的热点用户存入redis中,当访问时可以通过redis访问;
实现:定时任务设置访问量高的用户到redis,并修改访问用户逻辑;
代码不难,可以拓展学习:
定时任务:
// 缓存几个访问频率高的热点用户(需要保证该定时任务发生在访问数据存储到数据库后,记录的热点用户为前一天的热门用户)
@Scheduled(cron = "0 0,41 18 * * ? ")
public void doCacheUser() {
log.info("定时任务2=>开始向redis缓存热点用户");
RLock lock = redissonClient.getLock("bode:scheduleTask:cacheUser:lock");
try {
// 只有一个线程能获取到锁
if (lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
log.info("getLock: " + Thread.currentThread().getId());
// 取出访问前十的用户
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.orderByDesc(User::getVisit).last("limit 10");
List<User> userList = userService.list(queryWrapper);
// 将访问前十的用户存入redis
userList.forEach(user -> {
redisCacheUtil.setCacheObject("hot-user:" + user.getId(), user, 1, TimeUnit.DAYS);
});
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 只能释放自己的锁
if (lock.isHeldByCurrentThread()) {
log.info("unLock: " + Thread.currentThread().getId());
lock.unlock();
}
}
log.info("定时任务2=>缓存热点用户成功");
}
我这里设置的是一天执行一次缓存,如果用户量大的话可以设置几个小时执行一次;
缓存十个热点用户;
访问用户接口:
@GetMapping("/info/{id}")
public User getUserInfoById(@PathVariable Long id) {
// 查找redis中是否有该缓存用户
User user = null;
user = redisCacheUtil.getCacheObject("hot-user:" + id);
if (user == null) { // 如果没有该用户则查询数据库
user = userService.getById(id);
}
// 访问该用户一次访问量增加一次
// 先判断redis是否有该用户访问量缓存
Integer visit = redisCacheUtil.getCacheObject("visit:" + id);
if (visit == null) { // 如果没有则新建redis缓存并将该次访问量存入缓存(该用户访问量+1)
visit = user.getVisit() + 1;
} else { // 更新缓存(访问+1)
visit += 1;
}
// 新建/更新缓存(需要保证访问数据缓存时间要大于等于定时任务触发的周期,否则会出现缓存的数据还没有存入数据库就过期的情况)
redisCacheUtil.setCacheObject("visit:" + user.getId(), visit, 1, TimeUnit.DAYS);
user.setVisit(visit); // 更新一下返回信息
return user;
}
就多加了一个判断;
测试一下:
可以看看redis中的缓存情况:
十个用户都已经缓存成功了;
总结
我感觉这个应用案例就是一种思想:频繁读写操作需要设置缓存;所以不管以后遇到什么业务,比如点赞操作什么的都可以往这上面想一想;
同样热点数据也要注意缓存,比如一些热点话题、文章什么的;
如果对文章内容有问题也欢迎一起交流!