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 两个集合中的元素

扩展:通配符和泛型

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


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

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐