java 篇: 1.基础地基 2.设计原理 3.项目实战
分析产品原型:
表有了,用 mp 生成器,生成相关的实体。
实现签到功能接口:
docker exec -it redis redis-cli
这个命令的意思是:在运行中的 Redis 容器里执行 `redis-cli` 命令
| 部分 | 含义 | 说明 |
| `docker` | Docker 命令 | 调用 Docker 程序 |
| `exec` | 执行 | 在容器中执行命令 |
| `-it` | 交互式 + 终端 | `i`=交互模式,`t`=分配伪终端(两个通常一起用) |
| `redis` | 容器名 | 要进入的容器名称 |
| `redis-cli` | 要执行的命令 | Redis 的命令行客户端工具 |
返回的是旧值, 这里从 0 开始
bitfield bm get u2 0
| 部分 | 含义 | 说明 |
| `BITFIELD` | 位操作命令 | Redis 的位域操作命令 |
| `bm` | 键名 | 要操作的 key |
| `get` | 读取操作 | 从位域中读取值 |
| `u2` | 类型+位数 | `u`=无符号整数,`2`=占2个bit位 |
| `0` | 偏移量 | 从第0位开始读取 |
| 符号 | 含义 | 取值范围 |
| `u` | 无符号整数 | 0 到 2^n - 1 |
| `i` | 有符号整数 | -2^(n-1) 到 2^(n-1)-1 |
返回十进制的 3
前端不需要总的,所以用 @JsonIgnore 忽略了
注意划横线这几处
对于日期格式设置了静态 String 类型。
spring 默认返回 true or false,redis 当中是 0 or 1,返回 1,代表旧值已经有了,可以用此判断重复签到了
复习一下:
| 维度 | BadRequestException | BizIllegalException |
| 含义 | 错误的请求 | 业务非法操作 |
| HTTP状态码 | 400(客户端错误) | 通常 400 或 403 |
| 触发场景 | 参数格式错误、必填项缺失 | 业务规则违反、权限不足 |
| 使用者 | 客户端传错了数据 | 客户端做了不该做的操作 |
| 能否修复 | 修改请求参数即可 | 可能需要权限或改变状态 |
①:
| 部分 | 含义 |
| `redisTemplate.opsForValue()` | 获取 Redis 字符串操作对象 |
| `.bitField(key, ...)` | 执行 BITFIELD 位域操作 |
| `BitFieldSubCommands.create()` | 创建位域命令构建器 |
| `.get(...)` | 执行读取操作 |
| `BitFieldType.signed(len)` | 有符号整数,占 len 个 bit 位 |
| `.valueAt(0)` | 从第 0 位开始读取 |
| `List` | 返回结果列表 |
为啥返回的是集合呢,因为这当中可以有很多个子命令,但是这里只执行了一个子命令,得到 Long 的十进制数。
②:为了避免下面 while 当中频繁的拆箱,提前进行了强转处理。
③:复习一下
`>>>` 和 `>>` 的区别:
| 操作符 | 名称 | 行为 |
| `>>>` | 无符号右移 | 左边补 0 |
| `>>` | 有符号右移 | 左边补符号位 |
这里奖励积分还没有实现,先设置个 0,测试一下
这里采用 0 和 1,在前端看来就是 false or true,并且可以减少请求交互过程中数据的传输,本质是用更少的数据量表达相同的信息,用 1 个 bit 代替 5 个字节
`Byte[]` 是 Java 中的字节数组对象类型。Byte 为 bite 基本数据类型的包装类。
跟前面统计那部分类似,只不过没有传入参数,需要自己拿一下。后面就是把读取的结果转为 Byte[] arr。
实现保存积分明细功能接口:
定义了学习有关的交换机,以及积分相关的路由键
这里就实现写回答和签到记录积分为例子
消息队列名字换了下,然后交换机不变,都是学习相关的,但是路由键肯定得换了。但是下面的是针对签到的,而签到,它加的积分不是固定的,连续签到多天加的积分不同。那这就提前定义了一个枚举类,就不用 map 放 userid 和 points 了,
最后还得把积分的类型传进去。接着跟着流程完成具体的代码编写:
先来判断下,是否有积分上限,其实传入的 type 枚举类当中就有这么一个属性。
因为"每日签到" 一天就一次,"课程评价"对课程评价,也就一次,又不能重复,所以为 0,没有设置上限。那这里就判断 maxPoints 是否 >0 就行了。
如果有上限,就需要进一步判断,是否超过上限。如果没有上限,直接保存积分记录。
对于有上限,我们需要用 userId,type,时间去数据库查现在已得的积分
int currentPoints = queryUserPointsByTypeAndDate(userId, type, begin, end);
那由于 lambda()当中没有.SUM()方法,所以得手写下。
混合使用 LambdaQueryWrapper 和手写 SQL 的典型用法
`Constants.WRAPPER` 的作用
`Constants.WRAPPER` 的值是 `"ew"`(MyBatis-Plus 内部定义)
`@Param(Constants.WRAPPER)` 相当于 `@Param("ew")`
所以在 SQL 中可以用 `${ew.customSqlSegment}` 获取 wrapper 生成的 SQL 片段
`${ew.customSqlSegment}` 会生成什么?
假设参数为:
userId = 1001
type = "SIGN" (签到)
begin = "2026-01-01", end = "2026-01-31"
那么 `${ew.customSqlSegment}` 会被替换为:
`customSqlSegment` 是 MyBatis-Plus 中 `QueryWrapper` 的一个内部属性,它存储了 wrapper 构建的完整 SQL 片段(包括 `WHERE` 关键字和所有条件)。
就是传入的 wrapper 参数,通过 @Param(Constants.WRAPPER)传到了上面的 ew,.customSqlSegment 获得完整 sql 片段,与前面部分拼接构成了完整的 sql 语句。
最后别忘了健壮性处理
因为 userId 肯定不为空的,那 type 和 begin、end,正常情况肯定不为 null,这里也判断一下把。下面如果 wrapper 为 null,那得到的 points 为 null,所以需要判断一下。
那这样就查到了已得积分,如果超过了上限,那就没有必要保存记录了。没有的话,那也不能直接保存,如果说已得积分,加上现在的积分,超过了上限,那就有问题了。所以真实记录的积分应该是上限-当前积分。
ok,完事。
之后在签到里发送消息,测试一下
先注入 RabbitMq 客户端
rewardPoints 是连续签到得到的积分,这里 +1 是本次签到得到的积分,就是传的是总积分。
完整的流程是这样的:
这里发送消息到交换机,并设置了路由键,监听器绑定了队列,监听到队列有消息,开始处理消息,保存数据到数据库。
下面开始测试:
先删除 Redis 当中原先的签到记录,发送请求
redis 当中又有了这个数据
再看 RabbitMq 当中
也是有消息的
并且数据库当中也有记录的数据
从时间上判断也是正确的。
实现查询我的今日积分接口:
这里查询跟之前根据用户类型和数据查询类似,不过这里是要返回每个类型的,不是单个了。
记得起个别名,这样才能和 PointsRecord 当中的属性对上,然后这里是分组查询
查到之后封装返回
进行测试:
测试通过,注意查的是今日的,你得看下数据库当中是否有数据
前端应该是这样的,不过我没看
发现还是不行,突然看到
这里怎么是 java 21(图中是已改过的)
而其他导入存在的是这个 java 11 这样的形式
喔凸(艹皿艹 ),原来是这里没配置 JDK 版本,改好后,果然不报错了。
但是,重测,还是出现了 Learning 下游业务并没有执行,比对了代码,看了 rabbitMq 相关的信息,发现没啥问题,然后相当红温,于是求助企鹅龙虾。先是判断,前端没有传递 `bizType` 参数,于是在
结果发现前端有传
再去看日志发现
`String.format("{}.times.changed", "QA")` 应该返回 `"QA.times.changed"`,而不是 `{}.times.changed`。
这说明传入 send 的 `bizType` 实际上是 `null`。
所以就是在 addLikeRecord 方法和模板化过程中间出了问题
然后列出了这几种可能性
- 日志打印和 send 调用之间 bizType 被清空了(不太可能,String 是不可变的)2.
- RabbitMQHelper.send 的日志方法打印的参数顺序有问题
- Learning 服务连接 RabbitMQ 时出现了问题
于是通过临时硬编码的方式
重新测试,发现好了,下游服务的数据库发生了变化。
所以根本原因是
`String.format` 格式化失败。
一查我的妈呀,
- 用 `String.format()` 时 → 用 `%s`
- 用 `StringUtils.format()` 时 → 用 `{}`
怪不得,我一直用的是 `String.format()` 搭配 `{}`,路由键一直是错的,能处理就怪了
最终也是通过了。不过这里还有个小 bug
理论上会有最新回答的
但我前端测试是没有的
不过我也不想改了,后面需要改再说吧。过
功能改进:
原来的流程,涉及到多处数据库的读写,因为这是网课,对于点赞功能没有特别大的需求,再加上设置了用户 id 和业务 id 的联合索引,大部分能满足需求。
改进后
这里对于新增点赞记录,采用 Set,天然保证数据唯一性,新增成功输出 1,失败输出 0.统计采用 ZSet,多实例部署水平扩展,进行刷盘,那为了避免重复刷盘,就需要查到后删除,这样其他实例才能去处理下一部分。不用 Map 是因为它没法保证,查和删原子性操作,且里面的元素是无序的,需要通过 SCAN 扫描全部,数据量是不确定的,这可能会给数据库造成压力。用 ZSet 因为它可以根据 score 进行排序,ZPOP 查删原子性,且可以设置查询数量,避免一次性取过多的数据,给下游造成太大压力。
然后还有一点要提的就是 bigKey 的问题,按业务拆成多个 Key,那当然如果某个业务当中还是出现 bigKey 的问题,继续拆,可以将业务 id 哈希运算然后对 10 取余,余数不同,Key:n 再次打散
改造点赞和取消点赞接口:
这里用接口,因为默认为 public final
前缀命名规律
| 前缀 | 拆解 | 含义 |
| `likes:set:biz:` | likes + set + biz + : | 点赞业务的 Set 结构,存储业务ID |
| `likes:times:type:` | likes + times + type + : | 点赞业务的次数字段,按类型分类 |
层级结构:
copy 原先的,然后把原先的 @Service 注释掉
第一个用 redisTemplate,参数都是 String 类型,得到的结果是 Long 类型,第二个涉及到自动拆箱,得先判断不为 null
下面为主要流程,
框框里也是对于拆箱的处理,因为 score 为 double 类型
改造查询点赞状态接口:
可以直接通过这个命令,查看 id 在不在当中,但是只能看一个业务。想看是不是在其他业务,就得在去 for 执行。
如果客户端与 Redis 服务端之间的距离非常长,导致网络延时很高,那总的网络消耗就会很大。
这里采用批处理的方式
批量发送 n 条命令,执行 n 条命令,返回 n 个结果。
代码实现:
原先的 for 写法(注释中),挨个判断有木有,如果有,把 bizId 加到 Set集合中。
现在以管道模式执行 Redis 命令,:所有命令的执行结果列表(顺序与添加命令的顺序一致)
`RedisCallback` 回调
- 作用:在 Redis 连接中执行自定义操作
- 特点:可以获取底层的 `RedisConnection` 对象
new RedisCallback() 本质上就是个匿名类
匿名类的特征
| 特征 | 说明 |
| 没有类名 | 定义时不需要写类名 |
| 一次性使用 | 通常只用一次 |
| 立即实例化 | 定义的同时就创建对象 |
| 编译器生成名字 | 编译后会有 `ClassName$1.class` 这样的文件 |
| 不能重复使用 | 无法创建第二个实例 |
里面的 doInRedis 方法默认返回 null,但没啥干系,需要的结果都在 objects 当中。
当然还可以优化一下
匿名类换成 lambda 表达式,鼠标放在 RedisCallback上有黄色小灯泡,可以立即变的
框里面就是 stream 仙人写法,可读性差,看看就行
定时任务持久化缓存数据:
现在启动类上加上
然后在 remark 模块,新建 task,因为这个模块来统一管理点赞相关的数据,落库都在这完成。
@Scheduled(fixedDelay = 20000),里面也可以用 cron 表达式,定时任务执行时间。前面的 BIZ_TYPES 和 MAX_BIZ_SIZE 可以写到配置文件里,nacos 来管理。这里 MAX_BIZ_SIZE 限制一次性处理最多的业务 id.调用 recordService 进行处理
在原来的 ServiceImpl 和新建的 ServiceRedisImpl 都去实现这个.readLikedTimesAndSendMessage 方法,不过 ServiceImpl 给个壳子就行,避免报错,在 ServiceRedisImpl 具体实现。
那肯定得执行 redisTemplate 当中查找并删除方法,那选 popMin 还是 popMax 呢,这里选小的上,因为小的对数据更加敏感,并且数据量小,估摸着都不会变 ,尽早刷盘把它在 Redis 当中干掉。那大的呢,体量大,差一俩个无所谓,并且数据变化频繁,那尽量减少刷盘的频率。
那这里需要 key 和 maxBizSize 参数,然后加入 key. 得到的 tuples 为元组,就是键值对咯。那它跟 LikedTimesDTO 对应。那后面就是一个个将它们放到 list 集合中,统一发送 MQ
然后修改 learning 模块的 Listener
这里不是处理一个了,而是整个 list 集合了。调整一下,ok
进行测试
测试通过
20s 后这个数据就入库,Redis 当中就没了
压测
新建线程组,右键添加相关组件。当前页面配置说明
当请求失败时,选择如何处理:
| 选项 | 含义 | 使用场景 |
| 继续 | 忽略错误,继续执行 | 压力测试,统计真实错误率 ✅ |
| 启动下一进程循环 | 跳过当前,开始下一次循环 | 业务依赖时 |
| 停止线程 | 当前线程停止,其他继续 | 某个用户失败后不再重试 |
| 停止测试 | 所有线程立即停止 | 严重错误时 |
| 立即停止测试 | 强制停止,不等待 | 紧急情况 |
这里看下定义了解下就行
| 配置项 | 你的设置 | 含义 | 推荐值 | 说明 |
| 线程数 | 2000 | 虚拟用户数量 | 100 → 500 → 2000 逐步增加 | 从低到高测试服务器承受能力 |
| Ramp-Up时间 | 空白 ❌ | 启动所有线程的时间(秒) | 60-120秒 | 空白=瞬间启动2000线程,容易压崩服务器 |
| 循环次数 | 永远1 ⚠️ | 每个线程执行几次 | 1 或 永远 | 选择"1"或"永远",不要两个都选 |
| Same user on each iteration | 未勾选 | 是否保持同一用户状态 | 看需求 | 勾选=保持Cookie/会话;不勾选=每次模拟新用户 |
| 延迟创建线程直到需要 | 未勾选 | 何时创建线程 | 默认不勾选 | 勾选=节省内存;不勾选=提前创建所有线程 |
| 调度器 | 未勾选 | 启用定时控制 | 压力测试时勾选 | 不勾选=手动停止;勾选=自动停止 |
| 持续时间(秒) | 未设置 | 整个测试运行时长 | 300秒 | 需要先勾选"调度器"才显示 |
| 启动延迟(秒) | 未设置 | 延迟多久后开始 | 0-10秒 | 需要先勾选"调度器"才显示 |
| 字段名 | 你的数值 | 含义 | 判断标准 |
| Label | 点赞 | 取样器/请求的名称(你在HTTP请求中填写的名称) | 用于区分不同的请求接口 |
| # 样本 | 2000 | 发送的请求总数(样本数量) | 数值越大,结果越可信 |
| 平均值 | 2ms | 所有请求的平均响应时间 | 越小越好✅ <200ms 优秀⚠️ 200-500ms 一般❌ >500ms 较差 |
| 最小值 | 1ms | 最快的请求响应时间 | 反映最佳情况 |
| 最大值 | 7ms | 最慢的请求响应时间 | 反映最差情况波动越小越稳定 |
| 标准偏差 | 0.67 | 响应时间的波动程度 | 越小越稳定✅ <50 波动小⚠️ 50-100 波动中等❌ >100 波动大 |
| 异常 % | 0.00% | 请求失败的比例 | 越小越好✅ <1% 优秀⚠️ 1-5% 可接受❌ >5% 有问题 |
| 吞吐量 | 966.7/sec | 每秒处理的请求数(TPS/QPS) | 越大越好反映服务器的处理能力 |
如果对你有帮助的话,请点赞,关注,收藏。热爱可抵一切!👍 ❤️ 🔥