黑马点评项目笔记分享

该博客围绕Java项目展开,介绍了好友关注、附近商户、用户签到、UV统计等功能的实现。详细阐述了Redis的多种操作,如GEO、BitMap等。同时,分析了缓存穿透、雪崩、击穿的概念及解决方案,还提及利用ThreadLocal保证线程隔离和定义Redis常量工具类等内容。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

项目内容

好友关注

代码

 /**

    * 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的作用主要有以下几个方面:

  1. 线程隔离:在多线程环境下,使用ThreadLocal可以将变量与线程关联起来,实现线程隔离。每个线程都拥有自己独立的变量副本,不会被其他线程访问或修改。这在一些需要线程安全的场景中非常有用,比如在多个线程中使用同一个对象的不同副本,避免并发访问时的竞争和冲突。
  2. 线程上下文传递:在多线程任务执行过程中,可能需要在不同的任务之间传递一些上下文信息。通过将上下文信息存储在ThreadLocal中,可以方便地在不同任务或方法中访问和传递这些信息,而不需要显式地进行参数传递。
  3. 避免参数传递:有些情况下,某些变量需要在多个方法中频繁使用,但并不希望将这些变量作为方法的参数传递。使用ThreadLocal可以将这些变量保存在ThreadLocal中,避免反复传递参数,提高代码的简洁性和可读性。
  4. 线程状态保存:有些场景下,需要在多个方法调用之间保存线程的状态信息,以便后续方法可以继续使用该状态信息。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相关的常量

  1. LOGIN_CODE_KEY:表示存储登录验证码的键前缀,后面会加上具体的用户标识。
  2. LOGIN_CODE_TTL:表示登录验证码的过期时间,单位为秒。
  3. LOGIN_USER_KEY:表示存储登录用户的键前缀,后面会加上具体的用户token。
  4. LOGIN_USER_TTL:表示登录用户的过期时间,单位为秒。
  5. CACHE_NULL_TTL:表示存储空值缓存的过期时间,单位为秒。
  6. CACHE_SHOP_TTL:表示存储店铺缓存的过期时间,单位为秒。
  7. CACHE_SHOP_KEY:表示存储店铺缓存的键前缀,后面会加上具体的店铺标识。
  8. LOCK_SHOP_KEY:表示存储店铺锁的键前缀,后面会加上具体的店铺标识。
  9. LOCK_SHOP_TTL:表示店铺锁的过期时间,单位为秒。
  10. SECKILL_STOCK_KEY:表示存储秒杀库存的键前缀,后面会加上具体的商品标识。
  11. BLOG_LIKED_KEY:表示存储博客点赞信息的键前缀,后面会加上具体的博客标识。
  12. FEED_KEY:表示存储动态信息的键前缀,后面会加上具体的动态标识。
  13. SHOP_GEO_KEY:表示存储店铺地理位置的键前缀,后面会加上具体的店铺标识。
  14. 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

进行操作时token没有续上

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值