Java 篇-项目实战-天机学堂(从0到1)-day8
java 篇: 1.基础地基 2.设计原理 3.项目实战
实时排行榜思路分析:
积分累加:
| 键名(相当于文件名) | 成员(行) | 分数(列) |
| z1 | u1 | 18 |
| z1 | u2 | 25 |
| z2 | u1 | 100 |
查询 提供键和值
查询排行
从小到大排,从 0 开始
从大到小排,从 0 开始
开始编写代码:
先定义前缀
这里时间戳得格式化,所以需要在 common 包下的 utils 当中加个格式化器
这里没有":"了,因为前面没有 uid 了
接下来就是在 PointsRecordServiceImpl 当中,添加记录后在增加一步,累积积分数据到 Redis 的 SortedSet
进行测试:
重新签到一下
有记录,测试通过
实现学霸积分榜接口:
展示数据可以分成两部分,一个是我的积分和排名,另一个是榜单列表,将二者拼接返回。为啥要单独查我的呢,因为你不一定能上榜。
之后又得分查的是当前榜单,还是历史榜单。避免臃肿,以及达到逻辑清晰的目的,于是用 4 个方法来实现。
咱们先来实现查询当前页面:
对着表来做:
为了避免每次都传入 key,所以我们事先把 key 绑定住,之后我们只需要传入 userId,就可以查询用户的积分和排名了。最后封装的时候 rank 记得 +1.
接着就是查询榜单列表:
注意亮点:
①:记得带上 WithScores
②:并不是每页都是从 1 开始的,是从 from+1 开始的
再后面就是封装了
这没啥好说的,之前都写过很多个了,逻辑理清,就行。
测试一下,通过了
"萝丝",名字没有显示,是因为数据库没有
数据库的分区和分表(历史排行榜):
分区(数据存储方案):
表数据越多,.ibd 文件越大,小的时候可能只有几十兆,几百兆,当数据规模很大时,可能达到几个 GB,甚至 TB,占用的分区越多,做数据检索时,经历的磁盘 IO 次数也会越多,性能也会越差。
水平拆分:按赛季切
**优点:**
**提高数据检索性能:**现在一个赛季就放在.ibd 文件当中了,占用的分区也就小了,磁盘 IO 次数也就少了。
**提高统计性能:**对整张表做一种类似求和或者计数的操作,原来得在整张表进行操作,现在可以用多个线程并行处理多个分区的数据,最后汇总。
**打破磁盘容量限制:**对表做了分区以后,还可以给每个分区指定存储位置,不同的分区可以在不同的磁盘。那这就可以无限扩容。
**根据分区清理数据效率高:**比如说某个数据太旧了,根本没人看了,那你直接把对应的.ibd 文件删了,就行。比执行 Mysql 语句的效率高。
**对业务没有影响:**虽然在物理上拆出来多个表,但是在逻辑上还是一张表,意思说进行 CRUD 时,业务逻辑根本不用改
**缺点:**
**分区字段必须是索引的一部分:**比如将 season 字段作为分区字段,因为 season 字段有很多重复值,不可能单独建立索引,只能走联合索引,和主键绑在一起,mp 不支持联合主键,而它本身又是很多重复的,再建立索引,其实没啥意义。
**分区方式不够灵活:**① 按范围(range( , ))② 按枚举(男,女)③ 按哈希值(某个字段求哈希值对数量取模)
**只支持水平分区:**
分表(表设计方案):
**水平分表:**逻辑和实际都是多张表,需要通过业务逻辑判断,拆分灵活
**垂直分表:**宽表会导致单行数据量过多,那存储的数据量(行数)就会减少,查询性能下降。只想要当中某些字段,却把所有字段查出来了,性能差。再说,如果有个字段更改频繁,写频繁,那它会加锁,那就影响查了,性能变差。所以宽表不可取,得按字段进行拆分成不同表,但他们得有关联(外键关联),CRUD 时注意一下。
实际开发中,追求这种灵活性,以及垂直拆分的能力,因此都会采用分表的方案。
分库和集群:
这里的学习库集群是结构相同,但是数据不同,水平分表。
课程库不一样,是为了防止高并发,所以数据是相同的。
定时生成历史榜单表:
1.获取上月时间:当前时间减去一个月的时间,当然减去 2 天也可以。
2.查询赛季 id
3.创建表
le,ge 和 lt,gt 的区别,前者为闭区间后者为开区间。
`Optional` 是 Java 8 引入的容器类,用来优雅地处理可能为 `null` 的值
`oneOpt()` 的含义
- one:查询一条记录
- Opt:Optional 的缩写
- 返回类型:`Optional`,而不是直接返回对象或 `null`
查询结果与 Optional 的对应关系
- `Optional` 是一个包装器,明确告诉调用者"这个值可能不存在"
- 强制你处理空值情况,避免 `NullPointerException`
- 配合 `orElse()`、`ifPresent()`、`orElseThrow()` 等方法使用更安全
return 注释了,两者写法都可以,不过没注释的优雅一点。
创建表部分:
和 本质是 的特例,用 delete 标签都能跑
网友提问:为什么每次判断 null 值的时候有的时候是 return 有的是返回一个空的集合,有的时候又是抛出异常,怎么区别啊?
判断 null 后如何处理,取决于业务语义和调用方的期望。
**1. 返回(静默处理) - 正常业务场景**
当 null 是预期内的正常情况,不需要调用方关心时:
适用场景:
- 批量处理中跳过无效数据
- 定时任务、触发器
- 可选功能(有就执行,没有就算了)
- 查询列表为空也算正常结果
**2. 返回空集合 - 查询结果的正常情况**
当方法契约明确表示会返回集合,空集合比 null 更合理:
适用场景:
- 查询方法返回集合类型
- 避免调用方判空,可以直接 foreach
**3. 抛出异常 - 异常业务场景**
当 null 代表程序无法继续的致命错误时:
适用场景:
- 前置条件不满足(参数校验失败)
- 依赖的核心数据不存在
- 配置缺失导致系统无法运行
- API 接口参数错误
总结口诀
- 正常可预期 → return / 空集合
- 错误不应当 → 抛异常
- 可选返回值 → Optional
- 批量循环 → 跳过(continue/return)
- 核心逻辑 → 必须抛异常
关键是保持一致性:同一个项目中类似场景要用相同处理方式,否则会混乱。
学习服务压力很大,所以不可能单点去部署,那肯定会负载均衡,水平扩展,部署成多个实例。那这样就会有多个定时任务,那其实只需要一个就够了。然后表创建完了,还需要将 Redis 当中的数据持久化,那就需要后续的定时任务去做了,那这两个定时任务需要保证先后顺序。
分布式任务调度的常见技术:
XXL-JOB 快速入门:
adminAddresses:从.env 文件当中读取,填虚拟机的地址和端口就行。
appname:注册到调度中心的这个应用的名称,一般都用当前微服务的名称,如 learning service
ip 端口:不用配,默认自己读取
accessToken:访问令牌提前在调度中心配好了,作为访问授权的一个密钥
logPath:运行时保存的一个目录
logRetentionDays:日志保存的有效期
属性配置:
这是一个 Spring Boot 配置属性绑定类,用于将配置文件(`application.yml` 或 `application.properties`)中以 `tj.xxl-job` 开头的配置项自动绑定到这个 Java 对象中。
主要就写划红线的字段,从黄色划横线的配置文件当中去读。
读完了之后,配置执行器,把东西一个个塞进去。这就完成了自动装配了。
需要我们做的是在 yml 文件里,写配置的属性。当然这里也不用了,nacos 当中已经共享配置了
橙色划线保持一致
把原来的 @Scheduled 换成 @XxlJob,定义好任务。接着把执行器注册到调度中心,把任务注册到调度中心。启动之后,执行器会自动注册到调度中心,之后在管理页面填一下信息就完了,任务也是在管理页面当中去填。
自动注册不需要填机器地址,手动需要。
名称得跟微服务名称保持一致
IDE 上启动服务就有了地址(本机)
接下来就是注册任务
想测试就
直接点击保存
控制台就有 sql 语句输出
咦,怎么没看到建表语句
哦,原来
这个文件放在别的模块下了,cc 还是牛的
查看调度日志
MybatisPlus 的动态表名插件:
定义的表名处理器,只有一个方法,如果一个接口里只有一个方法,属于函数式接口,那就可以用 lambda 表达式代替
| 概念 | 含义 | 示例 |
| 逻辑表名(旧表) | 代码中写的表名(模板) | `points_board` |
| 真实表名(新表) | 数据库实际存在的表名 | `points_board_2025`、`points_board_2026` |
TableNameHandler 接口
- `sql`:要执行的 SQL 语句(通常用不到)
- `tableName`:逻辑表名(这里是 `points_board`)
- 返回值:替换后的真实表名
欧克,拦截器定义好后,然后注册到 mybatis-plus
`@ConditionalOnMissingBean` 的作用:
- 允许你自定义 `MybatisPlusInterceptor`
- 如果没有自定义的,就用这个默认配置
- 常见于框架/组件库中,提供默认实现
`@Autowired(required = false)` 是 Spring 的依赖注入注解,意思是:"尝试注入这个依赖,如果找不到就赋值为 null,不要报错"。
| 写法 | 找不到依赖时的行为 |
| `@Autowired` | ❌ 抛出异常,启动失败 |
| `@Autowired(required = false)` | ✅ 赋值为 `null`,正常启动 |
动态表名拦截器(可选)
- 判断是否有动态表名拦截器(就是你之前配的那个)
- 如果有就添加到拦截器链中
- 如果没有就跳过,不影响其他功能
注意注册顺序:
榜单持久化以及 XXLJob 工作流:
① 建表 ② 刷盘 ③ 清理 Redis 数据 ,三个分开,不能一起,如果后面失败,又得重新建表了。
①:
②:
当中分页查询方法之前写过
这里只需要将 private 换成 public,上面加上 @Override,然后父接口添加这个方法。
得到的 PointsBoard 类
包含这么多属性,但是我们只需要下面这几个
So
这里得把自增改成手动添加,
然后把排名信息写入 id
season 就没赋过值,不用管。
查到 1000 条就刷盘,别堆到一起,不如内存要爆了。
③:
DEL vs UNLINK
| 特性 | `DEL` | `UNLINK` |
| 删除方式 | 同步删除 | 异步删除 |
| 阻塞 | ❌ 会阻塞 Redis | ✅ 不阻塞 Redis |
| 返回结果 | 删除的 key 数量 | 删除的 key 数量 |
| 适用场景 | 小数据量 | 大数据量 |
| 版本要求 | 所有版本 | Redis 4.0+ |
DEL - 同步删除
UNLINK - 异步删除
数据跑批业务和 XXL Job 的分片广播:
刷盘之后记得 remove 线程域
ctl + d,新建实例,改下端口
改造以下地方
| 方法 | 返回值 | 含义 | 示例值 |
| `getShardIndex()` | int | 当前机器的分片序号 | 0, 1, 2... |
| `getShardTotal()` | int | 总分片数(机器总数) | 3 |
把两个启动之后:
持久化榜单数据,这里把轮询改为分片广播
清理 Redis 中的历史榜单和创建历史榜单表不需要动,就一个人去干就完了。
后面进行测试,这里我没做,图是视频当中的:
理论上是只有一个后台会显示建表 create 语句,但是 insert 都有
数据库数据有了
Redis 当中的没了
网友疑问:当一个实例完成后触发子任务时 子任务会导致 redis 数据删除 会使另一个没有完成的实例得不到 redis 数据 所以应该加个分布式锁吧
确实存在这个竞态问题。但普通分布式锁解决不了这个问题,因为这不是互斥访问的问题,而是"所有分片都完成后才能删除"的问题。
如果对你有帮助的话,请点赞,关注,收藏。热爱可抵一切!👍 ❤️ 🔥
