项目内容
好友关注
代码
/**
* Description: 保存博客
* date: 2023/8/2 8:55
* @parm:Blog blog 博客
*/
@Override
public Result saveBlog(Blog blog) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 2.保存探店笔记
boolean isSuccess = save(blog);
if(!isSuccess){
return Result.fail("新增笔记失败!");
}
// 3.查询笔记作者的所有粉丝 select * from tb_follow where follow_user_id = ?
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
// 4.推送笔记id给所有粉丝
for (Follow follow : follows) {
// 4.1.获取粉丝id
Long userId = follow.getUserId();
// 4.2.推送,存储的是博客的id,因为redis是存在内存的,如果存储整个博客的内容,这样对redis的压力比较大
String key = FEED_KEY + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
// 5.返回id
return Result.ok(blog.getId());
}
/**
* Description: 查询关注的人更新的博客,实现一个接收推送的功能
* date: 2023/8/1 23:41
* @parm:Long max 上一次的最小值
* @parm:Integer offset 上一次最小值的个数
*/
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
// 2.查询收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count
String key = FEED_KEY + userId;
//实现滚动分页
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
// 3.非空判断
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
// 4.解析数据:blogId、minTime(时间戳)、offset
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0; // 统计这次的最小时间
int os = 1; // 统计这次最小值的个数
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { // 5 4 4 2 2
// 4.1.获取id
ids.add(Long.valueOf(tuple.getValue()));
// 4.2.获取分数(时间戳)
long time = tuple.getScore().longValue();
if(time == minTime){
os++;
}else{
minTime = time;
os = 1;
}
}
// 5.根据id查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Blog blog : blogs) {
// 5.1.查询blog有关的用户
queryBlogUser(blog);
// 5.2.查询blog是否被点赞
isBlogLiked(blog);
}
// 6.封装并返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}
queryBlogOfFollow这个方法中接收两个参数max和offset,max就是上次查找的最小值作为这次reverseRangeByScoreWithScores的最大值,offset则是max的个数,因为有可能存在score一样的情况。
// 5.根据id查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
这段代码中使用StrUtil.join来拼接ids,还有使用ORDER BY FIELD是为了避免in范围查询导致查询的顺序不对,所以使用ORDER BY FIELD可以指定特定顺序。
reverseRangeByScoreWithScores是用于获取有序集合中按照分值逆序排序的元素,其中有几个参数
K key, 键
double min, 最小值,这里设为0,因为我们用时间戳作为score,时间最小就是0,不可能是负数
double max, 第一次是倒序第一个数,后面就是上一次的最小值(这里的最小值是上次有倒序count个的最小)
long offset,上一次最小值的个数
long count 需要获取多少个,通常和前端沟通好
在 SQL 查询中,“ORDER BY FIELD” 是一种用于指定特定顺序的排序方式。它允许你按照指定的字段值的顺序来排序查询结果。以下是对 “ORDER BY FIELD” 的解释:
“ORDER BY FIELD” 的基本语法如下:
SELECT column1, column2, ...
FROM table_name
ORDER BY FIELD(column, value1, value2, ...)
它有以下参数:
- column1, column2, …: 要查询的列。
- table_name: 要查询的表名。
- column: 要基于其值排序的列。
- value1, value2, …: 指定字段值的顺序。
当执行带有 “ORDER BY FIELD” 的查询时,查询结果将按照指定字段值的顺序进行排序。字段值与指定的顺序列表中的第一个值匹配的行将被排在最前面,依此类推。
附近商户
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式并返回
- GEOPOS:返回指定member的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.以后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能
用户签到
对比一条条插入数据库的记录,使用bitmap可以节省空间
BitMap的操作命令有:
- SETBIT:向指定位置(offset)存入一个0或1
- GETBIT :获取指定位置(offset)的bit值
- BITCOUNT :统计BitMap中值为1的bit位的数量
- BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
- BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
- BITOP :将多个BitMap的结果做位运算(与 、或、异或)
- BITPOS :查找bit数组中指定范围内第一个0或1出现的位置
注意默认是00000000
setbit 2023-8 0 1
setbit 2023-8 0 1
getbit 2023 2 //查看第三天,offset是跳过多少天
bitcount 2023-8 //查看该值有多少个1
bitfield 2023-8 get u1 0 //bitfield key operation(操作 get/set/incrby/overflow) u/i+数量(u代表无符号,i代表有符号) 跳过的数量
bitop and 2023-8-9 2023-8 2023-9
bitops 2023-8 0 0 1 //bitops key 0/1 start end
BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP|SAT|FAIL]
summary: Perform arbitrary bitfield integer operations on strings
since: 3.2.0
group: string
bitfield通常用作查询多个数,他的参数type是指需要返回带符号i的十进制还是无符号u的十进制,形式是
BITOP operation destkey key [key …]
summary: Perform bitwise operations between strings
since: 2.6.0
group: string
与操作and,或操作or,非操作not
import redis
# 连接到Redis服务器
r = redis.Redis(host='localhost', port=6379)
# 设置两个位串的值
r.set('bitstring1', '10101010')
r.set('bitstring2', '01010101')
# 执行位与操作
r.bitop('AND', 'bitresult', 'bitstring1', 'bitstring2')
# 执行位或操作
r.bitop('OR', 'bitresult_or', 'bitstring1', 'bitstring2')
# 执行位非操作
r.bitop('NOT', 'bitresult_not', 'bitstring1')
# 获取结果位串的值
result_or = r.get('bitresult_or')
result_not = r.get('bitresult_not')
# 打印结果
print(result_or) # 输出: b'11111111'
print(result_not) # 输出: b'01010101'
实现签到
需求:
实现签到接口,将当前用户当天签到信息保存到Redis中
思路:
我们可以把年和月作为bitMap的key,然后保存到一个bitMap中,每次签到就到对应的位上把数字从0变成1,只要对应是1,就表明说明这一天已经签到了,反之则没有签到。
我们通过接口文档发现,此接口并没有传递任何的参数,没有参数怎么确实是哪一天签到呢?这个很容易,可以通过后台代码直接获取即可,然后到对应的地址上去修改bitMap。
代码
@Override
public Result sign() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.写入Redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
复习知识点
获取日期信息
LocalDateTime now=LocalDateTime.now();
String date=now.format(DateTimeFormatter.ofPattern(“yyyyMM”));//注意月份一定要以MM,mm代表是分钟
int dayOfMonth=now.getDayOfMonth();//获取今天是这个月第几天
int month_value=now.getMonthValue();//获取月份
签到统计
问题1:什么叫做连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
Java逻辑代码:获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了
问题2:如何得到本月到今天为止的所有签到数据?
BITFIELD key GET u[dayOfMonth] 0
假设今天是10号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是10号,那么就是10位,去拿这段时间的数据,就能拿到所有的数据了,那么这10天里边签到了多少次呢?统计有多少个1即可。
问题3:如何从后向前遍历每个bit位?
注意:bitMap返回的数据是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。
需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数
有用户有时间我们就可以组织出对应的key,此时就能找到这个用户截止这天的所有签到记录,再根据这套算法,就能统计出来他连续签到的次数了
@Override
public Result signCount() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.获取本月截止今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET u14 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
if (result == null || result.isEmpty()) {
// 没有任何签到结果
return Result.ok(0);
}
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok(0);
}
// 6.循环遍历
int count = 0;
while (true) {
// 6.1.让这个数字与1做与运算,得到数字的最后一个bit位 // 判断这个bit位是否为0
if ((num & 1) == 0) {
// 如果为0,说明未签到,结束
break;
}else {
// 如果不为0,说明已签到,计数器+1
count++;
}
// 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
num >>>= 1;
}
return Result.ok(count);
}
UV统计
首先我们搞懂两个概念:
- UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
- PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
通常来说UV会比PV大很多,所以衡量同一个网站的访问量,我们需要综合考虑很多因素,所以我们只是单纯的把这两个值作为一个参考值
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖,那怎么处理呢?
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
PFADD key element [element ...]
summary: Adds the specified elements to the specified HyperLogLog.
since: 2.8.9
group: hyperloglog
PFCOUNT key [key ...]
summary: Return the approximated cardinality of the set(s) observed by the HyperLogLog at key(s).
since: 2.8.9
group: hyperloglog
PFMERGE destkey sourcekey [sourcekey ...]
summary: Merge N different HyperLogLogs into a single one.
since: 2.8.9
group: hyperloglog
知识点:通过info memory可以查看redis内存值
String[] values = new String[1000];
int j = 0;
for (int i = 0; i < 1000000; i++) {
j = i % 1000;
values[j] = "user_" + i;
if(j == 999){
// 发送到Redis
stringRedisTemplate.opsForHyperLogLog().add("hl2", values);
}
}
// 统计数量
Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
System.out.println("count = " + count);
这里实现了向redis中插入一百万条数据,通过查看info memory查看redis前后内存占用的差值可以发现hl2大小为14kb小于16kb
实际问题
缓存
缓存穿透
概念
大量的key不在redis也不在数据库中,由于数据库的并发量没有redis那么强,有可能导致数据库崩溃。
解决方案
- 返回空对象,在数据库查不到时返回空对象并设置较短过期时间存在缓存,下次再查时就直接返回
- 使用布隆过滤器,在查询缓存之前,先通过布隆过滤器对请求中的 key 进行快速过滤,判断 key 是否可能存在于缓存和数据库中。如果布隆过滤器判断不存在,则直接拦截请求,不再访问数据库。
- 异常数据缓存:对于某些知道是异常数据或不会被查询的 key,可以将其写入缓存并设置一个较长的过期时间。这样,当有请求查询这些 key 时,就可以直接从缓存中获取到数据,而不需要访问数据库。
缓存雪崩
概念
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
解决方案
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿
概念
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
解决方案
- 互斥锁:因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
- 逻辑过期:方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
思想
利用threadlocal保证线程之间隔离
ThreadLocal是Java中的一个线程局部变量工具类,它提供了线程级别的变量存储和访问。每个线程都可以独立地访问自己的ThreadLocal变量,而不会被其他线程干扰。ThreadLocal的作用主要有以下几个方面:
- 线程隔离:在多线程环境下,使用ThreadLocal可以将变量与线程关联起来,实现线程隔离。每个线程都拥有自己独立的变量副本,不会被其他线程访问或修改。这在一些需要线程安全的场景中非常有用,比如在多个线程中使用同一个对象的不同副本,避免并发访问时的竞争和冲突。
- 线程上下文传递:在多线程任务执行过程中,可能需要在不同的任务之间传递一些上下文信息。通过将上下文信息存储在ThreadLocal中,可以方便地在不同任务或方法中访问和传递这些信息,而不需要显式地进行参数传递。
- 避免参数传递:有些情况下,某些变量需要在多个方法中频繁使用,但并不希望将这些变量作为方法的参数传递。使用ThreadLocal可以将这些变量保存在ThreadLocal中,避免反复传递参数,提高代码的简洁性和可读性。
- 线程状态保存:有些场景下,需要在多个方法调用之间保存线程的状态信息,以便后续方法可以继续使用该状态信息。ThreadLocal可以存储线程的状态信息,并且保证线程安全,确保每个线程只能访问自己的状态数据。
需要注意的是,ThreadLocal虽然提供了线程级别的变量存储和访问,但并不代表线程安全。在使用ThreadLocal时,仍然需要注意并发访问的问题,避免出现数据竞争和不一致的情况。
在项目中拦截器RefreshTokenInterceptor中的prehandle方法中就调用了UserHolder.saveUser(userDTO);我们看一下UserHolder类
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
可以看到我们定义了私有且为final的静态常量线程池对象tl,通过set方法保存对象,get方法获得对象,remove方法移除对象。
工具类
定义一个工具类RedisConstants存储Redis相关的常量
- LOGIN_CODE_KEY:表示存储登录验证码的键前缀,后面会加上具体的用户标识。
- LOGIN_CODE_TTL:表示登录验证码的过期时间,单位为秒。
- LOGIN_USER_KEY:表示存储登录用户的键前缀,后面会加上具体的用户token。
- LOGIN_USER_TTL:表示登录用户的过期时间,单位为秒。
- CACHE_NULL_TTL:表示存储空值缓存的过期时间,单位为秒。
- CACHE_SHOP_TTL:表示存储店铺缓存的过期时间,单位为秒。
- CACHE_SHOP_KEY:表示存储店铺缓存的键前缀,后面会加上具体的店铺标识。
- LOCK_SHOP_KEY:表示存储店铺锁的键前缀,后面会加上具体的店铺标识。
- LOCK_SHOP_TTL:表示店铺锁的过期时间,单位为秒。
- SECKILL_STOCK_KEY:表示存储秒杀库存的键前缀,后面会加上具体的商品标识。
- BLOG_LIKED_KEY:表示存储博客点赞信息的键前缀,后面会加上具体的博客标识。
- FEED_KEY:表示存储动态信息的键前缀,后面会加上具体的动态标识。
- SHOP_GEO_KEY:表示存储店铺地理位置的键前缀,后面会加上具体的店铺标识。
- USER_SIGN_KEY:表示存储用户签名信息的键前缀,后面会加上具体的用户标识。
完善功能
退出登录
我在UserServiceImpl定义了一个全局变量tokenKeyForLogout在login方法中获取到token并保存下来,在logout方法中使用stringRedisTemplate.delete(tokenKeyForLogout);删除掉redis库中的登录token
发布时如果没有选择商铺将失败报错
Cause: java.sql.SQLException: Field ‘shop_id’ doesn’t have a default value