当前位置: 首页 > news >正文

Java 篇-项目实战-黑马点评-笔记汇总

java 篇: 1.基础地基 2.设计原理 3.项目实战


一.用户模块 :

总体思路:

两个拦截器,一个用于刷新 token,防止用户需要频繁登录,放 threadLocal。另一个是权限控制,控制登录的访问权限。

拦截器的作用于请求(一个路径代表一个请求),在其前后进行增强。

刚开始进入页面,请求头当中也没有 token,拦截器 1 直接放行,交给拦截器 2 处理,如果用户访问不需要登录的请求(白名单)就直接放行,如果访问需要登录的请求,就会判断 threadlocal 有无用户信息,如果没有就返回 401,前端处理,清除过期 Token。用户登录,手机格式校验(正则表达式),成功后,在 redis 当中保存对应的验证码,用户验证码输入正确后,就根据手机号查数据库当中有没有这个数据,没有就新建(相当于注册),然后 UUID 生成 Token,存在 redis 当中,(键就是 Token,值就是 userdto 的 map 形式),返回 Token 给前端,存在 LocalStorage 浏览器本地存储,之后请求读取 Token,放到请求头当中,进行传输。 注意这时候 ThreadLocal 当中还没有用户信息,当用户执行下一次请求时,就会拿现在请求头当中的 token 到 redis 找用户信息,如果没有过期的话就存到 ThreadLocal 当中,如果过期了话也直接放行,因为拦截器 2 只根据 ThreadLocal 当中是否有用户信息进行拦截,当一次请求结束,线程就会释放,ThreadLocal 就没有东西了。一次请求就是一个线程

1.发送验证码(sendCode)

核心代码:

接口测试:

在返回 JSON 数据时,自动忽略值为 null 的字段

流程图:

2.用户登录(login)

核心代码:

解释:当中 RandomUtil.randomString(10)随机生成长度为 10 的字符串

解释:

解释:生成一个 不带连字符 的 UUID 字符串作为 Token

解释:脱敏操作,更安全,将第一个对象的属性值 copy 到第二个对象,数据传输对象

.beanToMap 将 Java Bean 转为 Map

CopyOptions.create().setIgnoreNullValue(true)

setIgnoreNullValue(true)

.setFieldValueEditor()

字段值编辑器

(fieldName, fieldValue) -> fieldValue.toString()

lambda 表达式

接口测试:

流程图:

返回 Token 给前端,前端保存 Token 到 LocalStorage,后续请求都带上 Token(请求头:Autorization)

扩展:session

当中 login 有个 session 参数看到了吗,历史的痕迹

Session VS ThreadLocal

扩展:单体 vs 分布式 vs 微服务

注意:HttpSession session 参数没有使用,这是历史遗留问题

代码之前用 session 来存储登录状态,后来重构为使用 Redis+Token 的分布式方案

维度Session 方案Redis + Token 方案
架构有状态无状态
扩展性差(需要 Session 共享)好(任意扩展)
跨平台依赖 Cookie任意客户端
性能服务器内存压力大Redis 统一管理
故障恢复会话丢失会话仍在 Redis
微服务不适合天然适合
主动控制困难简单(操作 Redis)

服务器当中存东西就是有状态的,一个 tomcat 服务器存一个 session,那负载均衡切到其他的服务器,那不就没法用了么,就需要 session 同步,但是 redis 是共享的呀,

重点:拦截器认证功能

@Configuration 配置类注解,相当于配了个 xml 文件,Spring 扫描这个类,加载其中的 Bean 配置

public void addInterceptors(InterceptorRegistry registry)

registry.addInterceptor(new LoginInterceptor())

.excludePathPatterns(

设置不需要拦截的 URL 路径

`).order(1)`

设置执行顺序为 1,越大越后执行

.addPathPatterns("/**")

拦截所有请求

判断是否为 null 或者""

response.setStatus(401);设置 HTTP 状态码为 401

时序图:

项目当中没有显示设置线程池参数,采用默认设置

但设置了缓存线程池

无论是单机还是分布式发的请求,最终就是拿线程池当中的线程用,用完归还

补充:为啥先从数据库写到 redis,再从 redis 到 ThreadLocal,不直接从数据库写到 ThreadLocal 当中?

3.用户登出(logout)

核心代码:

4.用户签到(sign)

核心代码:

_//3.拼接 key_ String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM")); String key = USER_SIGN_KEY + userId + keySuffix;

int dayOfMonth = now.getDayOfMonth();

stringRedisTemplate.opsForValue().setBit(key, dayOfMonth-1, true);

接口测试:

登录后,记得加请求头

5.统计连续签到(signCount)

统计从今天往前的连续签到

返回一个十进制的数字,但后面和 1 与都是二进制来的

0101:表示第二和第四天签到,那第四天就是今天

0001:

与之后不为 0,末位不为 0,count+1

之后 0101 右移 1 位,变为 0010

和 0001 与之后,末尾为 0,直接返回了

都没签到就直接返回,月初第 1 天没签到,也直接返回

num = num >>> 1;

将 num 的二进制位向右移动 1 位,然后把结果重新赋值给 num

接口测试:

二.商户模块:

1.商户查询(queryShopById)

高并发关注的是请求量,并不是多个客户端,一个单机快速操作,发起多个请求。

注:查询前需要先预热缓存

此处 AI 添加了容器启动后预热的代码

_/**_ _ * 应用启动时预热缓存_ _ */_ @PostConstruct public void initCache() { _log_.info("开始预热商户缓存..."); try { // 1. 预热商户缓存(逻辑过期) preheatShopCache(); // 2. 预热 GEO 缓存 preheatGeoCache(); _log_.info("商户缓存预热完成"); } catch (Exception e) { _log_.error("缓存预热失败", e); } } _/**_ _ * 预热商户缓存(使用逻辑过期)_ _ */_ private void preheatShopCache() { _log_.info("开始预热商户缓存(逻辑过期)..."); // 查询所有商户 List<Shop> shops = list(); _log_.info("从数据库查询到 {} 个商户", shops.size()); // 使用现有的 saveShop2Redis 方法预热缓存 int successCount = 0; for (Shop shop : shops) { try { // 使用 30 分钟的逻辑过期时间 saveShop2Redis(shop.getId(), _CACHE_SHOP_TTL_); successCount++; } catch (Exception e) { _log_.error("预热商户缓存失败,商户ID: {}", shop.getId(), e); } } _log_.info("商户缓存预热完成,成功: {}/{}", successCount, shops.size()); } _/**_ _ * 预热 GEO 缓存_ _ */_ private void preheatGeoCache() { _log_.info("开始预热 GEO 缓存..."); // 查询所有商户 List<Shop> shops = list(); // 按类型分组 Map<Long, List<Shop>> shopsByType = new HashMap<>(); for (Shop shop : shops) { shopsByType.computeIfAbsent(shop.getTypeId(), k -> new ArrayList<>()).add(shop); } _log_.info("商户类型数量: {}", shopsByType.size()); // 为每个类型初始化 GEO int totalCount = 0; for (Map.Entry<Long, List<Shop>> entry : shopsByType.entrySet()) { Long typeId = entry.getKey(); List<Shop> typeShops = entry.getValue(); String key = _SHOP_GEO_KEY _+ typeId; // 批量添加商户到 GEO for (Shop shop : typeShops) { if (shop.getX() != null && shop.getY() != null) { try { stringRedisTemplate.opsForGeo().add( key, new org.springframework.data.geo.Point(shop.getX(), shop.getY()), shop.getId().toString() ); totalCount++; } catch (Exception e) { _log_.error("添加 GEO 数据失败,商户ID: {}", shop.getId(), e); } } } _log_.info("类型 {} 的 GEO 缓存预热完成,商户数量: {}", typeId, typeShops.size()); } _log_.info("GEO 缓存预热完成,总计: {} 个商户", totalCount); }

为什么这里会抛出异常?

因为查询缓存有不同的实现,这里采用逻辑过期,当中没有用到 sleep()这样的中断语句,但其他方案可能有这样的中断,需要抛出。

重点:缓存三大问题

逻辑过期:

RedisData 是一个自定义的包装类

unit.toSeconds(time):将传入的时间转换为秒

比如 time=30, unit=TimeUnit.MINUTES → 转换成 1800 秒

plusSeconds():表示 + ,当前时间 + 指定秒数 = 逻辑过期时间点

逻辑过期解决缓存击穿:

就是这里有个细节,抢到锁的那个线程,在提交线程池异步重建后,就执行返回旧数据操作了,不会等那个异步线程恢复完数据。

如果异步重建线程池当中的核心线程数都占满了,就会执行相关的策略,在队里排队或者拒绝策略。不过本项目当中没有设置。

创建锁

释放锁

就是用 redis 当中键不存在才能添加,限制的

缓存空值解决缓存穿透:

:R 是返回值类型,ID 是参数类型 ,相当于映射绑定关系

dbFallback 函数接口,具体实现由调用者提供

比如:R=Shop, ID=Long,表示根据 Long 类型的 id 查询 Shop 对象

流程图:

缓存当中为空,说明之前查过数据库了,发现没有,所以之后不用查了,直接返回。为 null 说明还没查过数据库,需要去查。但这有两个问题需要思考?

如果缓存没有数据会怎么恢复:先从数据库查询,写入 Redis,然后返回给客户端

① 数据库新增数据,缓存还是旧的

设置了过期时间,缓存过期,重新加载,达到最终一致性。

主动更新,数据库更新后,立即删除或更新缓存

② 大量不存在的数据,内存够用吗

内存够用是够用

不过可以加布隆过滤器或者限流或者进行参数校验

扩展:布隆过滤器

布隆过滤器是一种概率型数据结构,用来判断一个元素是否在集合中。本质就是位图,加在客户端和 redis 之间

底层是哈希实现的,所以会有哈希冲突,就会存在误判

接口测试:

重点:数据一致性

先更新数据库再删缓存

为啥先改数据库再删缓存呢,我的理解是假设线程先删缓存再改数据库,线程 A 把缓存删了,去改数据库,这时候线程 B 过来读,只能到数据库当中读,读操作通常比写操作快,那读到的就是旧数据,写入到缓存。之后线程 A 把数据库当中的数据改成新的了,就造成了不一致的现象。

那当然先改数据库,再删缓存理论上也存在不一致的现象,比如刚开始由于某种原因比如过期了,缓存当中没有这个数据,线程 A 去改数据库了,这时候线程 B 来读,读到的还是旧数据,线程 A 改好了,去删缓存,但缓存没有东西,删了个寂寞,之后线程 B 把旧数据又写回了缓存,造成了数据不一致。但是这种情况比较难发生,因为线程 B 读比 A 快,之后写到内存中也快,所以这俩个操作一般都是按顺序下来的,当中很小几率还能有 A 操作。

延迟双删,但第一次删除貌似没啥用,主要还是靠延迟,打快慢刀。

扩展:缓存池设计

2.更新商铺信息(updateShop)

核心代码:

接口测试:

3.根据商铺类型分页查询(queryShopByType)

基础配置:

MybatisPlusInterceptor 是 MyBatis-Plus 的核心拦截器

它可以链式添加多个内部拦截器(如分页、乐观锁、防止全表更新等)

PaginationInnerInterceptor:专门负责分页的拦截器

DbType.MYSQL:指定数据库类型为 MySQL

作用:拦截需要分页的 SQL,自动拼接 LIMIT 语句

调用 .page(...) 方法

MyBatis-Plus 拦截器捕获到这个调用

分页插件自动处理:

返回的 Page 对象包含:当前页数据、总条数、总页数等信息

缓存预热:

经纬度 → 比如 (116.397, 39.908)

编码算法 → 将二维坐标转换成一维整数

特点:

Score 越大 → 越靠近东北方向

Score 越小 → 越靠近西南方向

相邻的店铺 Score 也相邻

核心代码:

模式 1:普通分页查询(不传 x,y)

这里一个为 null,坐标就不能计算了,所以用 ||

query() 是 MyBatis-Plus 提供的快捷方法继承 ServiceImpl 后可以直接使用,等价于:lambdaQuery() 或 new LambdaQueryWrapper<>()

.eq("type_id", typeId) 添加查询条件:`WHERE type_id = ?` 筛选出指定类型的店铺

.page(new Page<>(current, DEFAULT_PAGE_SIZE))

执行分页查询 `current`:当前页码(从 1 开始) `DEFAULT_PAGE_SIZE`:每页显示条数(通常是 10)

current=2,当前页码

limit a 相当于取前面 a 条

Limit a,b 相当于跳过前面 a 条,取前面 b 条

所以推一下公式就为:limit(current-1)*size,size

模式 2:附近店铺查询(传 x,y)

先查出前 `end` 条(比如第 2 页就查出前 20 条)

在 Java 代码中用 `skip(from)` 跳过前 10 条

剩下的就是第 11-20 条

这种方法在页码越大时效率越低

按类型 ID 分开存储商户位置

RedisGeoCommands.GeoLocation

表示 name 字段是 String 类型

name 存储的是店铺 ID(如 "1001")

point 存储的是店铺的经纬度坐标

GeoResults>

为什么 results 可能为 null?

Redis 连接超时或失败

Redis 服务器返回异常

key 不存在(但通常 key 不存在会返回空结果集,不是 null)

list.size()<=from 检查是否足够,因为要跳过 from 条,不够就返回[]

list.stream().skip(from).forEach(result -> {

list.stream():将 List 转为 Stream

.skip(from):跳过前 from 条记录(实现分页)

.forEach():遍历剩下的每条记录

ids.add(Long.valueOf(shopIdStr));

Redis 中存的是 String 类型的 ID(如 "1001")

数据库主键是 Long 类型

需要转换类型才能用于数据库查询

Distance distance = result.getDistance();

distanceMap.put(shopIdStr, distance);

key:店铺 ID(String,保持与 Redis 一致)

value:Distance 对象(包含数值和单位)

根据 Redis 查询到的店铺 ID 列表,从数据库查询完整的店铺信息,并设置距离

String idStr = StrUtil.join(",", ids);

.in("id", ids)

MyBatis-Plus 的条件构造器

生成 SQL:`WHERE id IN (1001, 1005, 1021, 1033)`

.last("order by field(id, " + idStr + ")")

`.last()` 方法可以在 SQL 末尾拼接自定义语句

`.list()` 做了两件事:

  1. 执行 SQL 查询(真正去数据库查)
  2. 将查询结果封装成 List 集合(每条记录转成 Shop 对象)List

接口测试:

重点:整个数据变化

这里的 distance 是实时计算出来

4.根据商铺名称关键字进行模糊查询(queryShopByName)

核心代码:

name,和 current 都是可选项

`shopService.query()` 等价于:`new QueryWrapper()`

`.like(StrUtil.isNotBlank(name), "name", name)`

精确查询

接口测试:

三.秒杀模块:

传入要秒杀的优惠券 id,通过 threadlocal 获得用户 id,然后用 RedisIdWorker 在 redis 当中记录当天的优惠券出售数量,键 icr:user:login:2026:03:25 值:Long 出售数量 ,然后返回订单号(64 位的二进制,前 32 为时间戳,后 32 为该线程自增 ID,时间戳是毫秒级的,然后 ID 在一天内也是不会重复的)。此时就有了优惠券 id,用户 id,订单 id(也就是订单号)。接下来到 lua 脚本里,

优惠券 id 为 1001 的库存,下单优惠券 id 为 1001 的用户 id,这里用 Set 不会重复。最后返回 Long 型的 result(-1 库存不存在,1-库存不足,2-重复下单)。通过 lua 脚本检查后就开始后续要数据库的操作了,先把用户 id,优惠券 id,订单 id 放到 order java 对象中,JSONUtil._toJsonStr_(order),再将其序列化作为消息,通过 RabbitTemplate 转换并发送到交换机 X。交换机又把这个消息给 QA 队列,JSONUtil._toBean_(msg, VoucherOrder.class),将这个消息又转为 java 对象,接下来真正进行数据库操作了,用到乐观锁看 stock 是否 >0,然后再加上唯一索引(用户 id,优惠券 id),如果唯一索引重复了,就被 catch 相当于内部处理了。返回订单号给前端,(订单异步处理)。

1.分布式 ID 生成器(RedisIdWorker)

基于 Redis 的分布式全局唯一 ID 生成器,核心思想是:时间戳 + 序列号

redis 自增获取今日订单号

2.Lua 秒杀脚本
  1. 检查库存是否充足
  2. 检查用户是否已下单(防止重复秒杀)
  3. 扣减库存
  4. 记录用户订单

redis.call('sismember', orderKey, userId)这个结果只有 1 跟 0

最后的返回值为 Long 类型,所以后面才会把他转为 int 型

流程图:

3.秒杀下单流程

总览图:

JSONUtil.toJsonStr(order),将 java 对象序列化

`Channel` 参数用于手动确认消息,但当前代码没有使用,说明使用的是自动确认模式

4.RabbitMQ

核心代码:

这一步只操作 Redis,不涉及数据库,速度极快

execute 方法格式如下:

rabbitTemplate.convertAndSend("X", "XA", jsonStr);

用 redis 作为消息队列的缺点

  1. Redis 宕机仍可能丢失数据
  2. Redis 不是专业的消息队列
  3. 复杂场景下功能不够(延迟队列、死信队列等)

数据库为啥还要进行一次判断

扩展:其他实现方案

创建秒杀订单(mysql 操作,mq 调用)

核心代码:

分布式锁(Redisson)

核心代码:

流程图:

为啥用 Redisson 而不是 SETNX

四.社交模块:

1.博客点赞功能(updateLike)

核心代码

时间戳越大,就越晚点赞

流程图:

2.取最早五个点赞的用户(queryBlogLikes)

核心代码:

3.发布笔记 + 推送 Feed 流(saveBlog)

核心代码:

推模型

推是博主推,拉是粉丝拉。

4.滚动分页查询 feed 流(quertBlogOfFollow)

用时间戳作为游标

第一次 100 90 90 出现 1 次 所以接下来找 <=90 要跳过 1 个 再找 2 个

第二次 80 80 80 出现 2 次 所以接下来找 <=80 要跳过 2 个 再找 2 个

第三次 70

Feed 流 = 系统帮你把关注的人的内容

自动收集、按时间排好 放到你面前,让你直接刷

就是你每天刷微信朋友圈、微博的那个东西。

五.关注模块:

1.关注/取关功能(follow)

核心代码:

共同关注(followCommons)

`intersect(key, key2)` = 找出同时在 key 和 key2 两个集合中的元素

扩展:通配符和泛型

通配符是泛型的一种特殊应用,占位用的


如果对你有帮助的话,请点赞,关注,收藏。热爱可抵一切!👍 ❤️ 🔥

http://www.jsqmd.com/news/723549/

相关文章:

  • 一颗IPM如何省去8颗分立元件从工程计算看智能功率模块的设计价值
  • idea中使用免费claude code的claude-opus-4-6模型202604
  • 别再只盯着PCIe配置空间了!手把手带你玩转CXL RCRB与MMIO寄存器
  • MoltGrid:分子构象生成与3D网格化工具在AI药物发现中的应用
  • 【LeetCode: 划分字母区间】贪心算法
  • 时间晶体管理:软件测试从业者的前沿视角
  • 量子计算在数据可视化中的革命性应用
  • 终极跨平台模组下载方案:WorkshopDL让非Steam平台玩家也能畅享创意工坊
  • 洛谷 P1305:新二叉树 ← DFS + 哈希表优化
  • Windows上的安卓应用安装神器:APK Installer全面指南
  • 【超详细】Allan偏差+PSD八大可视化一文吃透:随机游走频率噪声从原理到画图全流程(附公式与工程避坑)
  • 魔兽争霸3终极助手:WarcraftHelper完整配置与功能详解指南
  • 倒计时126天:谷歌静默更新将彻底剥夺你的安卓所有权
  • 2026届学术党必备的降重复率网站横评
  • ARM中断控制器优先级寄存器解析与实战
  • 2026年围挡仿真草坪厂家选型推荐:仿真植物景观哪家好,仿真绿植造景,仿真草坪公司,仿真草坪哪家好,排行一览! - 优质品牌商家
  • 2026年Q2出国务工派遣服务核心能力深度解析 - 优质品牌商家
  • 5步掌握semi-utils:专业照片批量水印处理终极指南
  • 批量图片下载终极指南:3分钟学会高效采集Google、Bing、百度图片资源
  • 别再只会ChatGPT了!用Langchain+文心大模型,5步搭建你的专属知识库AI助手
  • Beyond Compare 5密钥生成器:三步获取永久授权的终极指南
  • 深入解析Google API变迁:从Plus到People
  • 2026届学术党必备的降重复率方案推荐
  • RimSort:告别《环世界》模组混乱的终极解决方案
  • 从‘漏电’看芯片可靠性:射频芯片Leakage测试的完整流程与参数优化心得
  • OO Unit 2 总结博客
  • 算法训练营Day18|有效的括号
  • 告别‘接口依赖’!用SoapUI 5.7.0快速搭建WebService本地Mock服务(附WSDL文件实战)
  • 数据人的工具瘾——以为在学新东西,其实在换皮
  • 2026钢结构精神堡垒技术解析:靠谱厂家判定与选型推荐 - 优质品牌商家