Terminal 4.

temp

2025/11/28
loading

未整理的文件

在登录拦截器中封装全局上下文,基于ThreadLocal实现线程级用户身份共享,减少重复查询

  • 为什么要用 ThreadLocal? 不用它会有什么问题?(回答重点:线程隔离、减少参数透传)
  • ThreadLocal 的底层原理是什么?(回答重点:Thread 内部的 ThreadLocalMap,Key 是弱引用)
  • ThreadLocal 为什么会内存泄露? 你在项目中是怎么处理的?

  • 为什么 Key 要设计成弱引用? 如果是强引用会怎样?

    如果 Key 是强引用,即使业务代码里已经把 ThreadLocal 变量设为 null(不再使用了),但因为当前线程的 ThreadLocalMap 还持有它的强引用,这个 ThreadLocal 对象就永远无法被回收,导致内存泄露。

    Value 是业务真正需要的数据。如果 Value 也是弱引用,可能你刚存进去,还没来得及用,就被 GC 回收了。

  • 拦截器中 afterCompletion 的作用是什么? 为什么必须在这里调用 .remove()

  • 如果是异步编排(CompletableFuture)或开启了新线程,上下文还能拿到吗?( 阿里开源的 TransmittableThreadLocal

  • 在高并发下,如果使用线程池,ThreadLocal 会有什么坑?(回答重点:线程复用导致数据错乱)

    线程池中的线程是复用的。如果线程 A 完成了请求但没有清理 ThreadLocal,当请求 B 进来复用了这个线程时,ReqInfoContext.getReqInfo() 拿到的可能是线程 A 的用户信息。

确认以下细节:

  1. 拦截器的注册顺序: 确认你的 LoginInterceptor 是否在其他需要用户信息的拦截器之前执行。
  2. ThreadLocal 的封装: 是否定义了一个 UserHolderBaseContext 工具类?里面是否有 remove() 方法?
  3. remove() 的调用时机: 确认 remove() 是写在拦截器的 afterCompletion(视图渲染后)还是 postHandle(方法执行后)?(正确做法是 afterCompletion,确保异常情况下也能释放)。
  4. 用户信息字段: 你存的是完整的 UserDTO 还是仅仅一个 UserId?(建议存精简后的 DTO,减少内存占用)。

“在项目中,很多业务模块(如订单、收藏、评论)都需要获取当前登录用户的信息。如果每次都从 Session 或 Redis 里查,或者在 Controller 到 Service 的每一层都手动传 userId 参数,代码会非常臃肿且增加数据库压力。所以我设计了一个登录拦截器+ThreadLocal的方案。”

“具体流程是:当请求到达后端,拦截器会先校验 Token。校验通过后,解析出用户信息,并存入自定义的 UserContext 容器中。

这样在整个请求的下游(Service、Mapper、甚至工具类)中,只需要通过 UserContext.getUser() 就能直接获取当前线程绑定的用户信息,实现了线程级隔离。”

“在使用过程中我也重点考虑了内存泄露问题。因为 Web 服务器通常使用线程池,线程是循环利用的。如果请求结束后不手动清理,该线程被复用时不仅会造成数据错乱,还会导致 Entry 对象无法被回收。

因此,我利用拦截器的 afterCompletion 回调,在请求结束后的最后阶段强制调用 threadLocal.remove(),确保及时清理内存。”


构建Caffeine+Redis 多级缓存体系,提升首页流媒体、专栏等高频热点数据的访问吞吐能力,首页响 应耗时降低至80ms以内

  • 你是如何保证 Caffeine、Redis 和数据库三者数据一致的?(先删缓存还是后删?本地缓存怎么同步更新?)
  • 如果某个节点修改了数据,其他服务器节点的本地缓存(Caffeine)如何失效?(回答重点:引入消息队列如 Redis Pub/Sub 或 RocketMQ 进行广播)。
  • 为什么要用 Caffeine 而不是直接用 Guava 或 HashMap?

    • HashMap 没有淘汰策略,会撑爆内存;
    • Guava 采用的是 LRU(最近最少使用)算法,容易被突发性的‘冷数据扫库’污染。
    • Caffeine 使用了 W-TinyLFU 算法:它不仅看数据是不是刚被访问(Recency),还会记录数据访问的频次(Frequency)。

    简单说:它能精准识别出哪些是真正的‘热数据’,哪些是‘偶尔被看一次’的干扰数据。这让首页这种热点集中的场景,命中率比 Guava 高出一截。”(踢人的时候先踢最不热的数据)

  • 你的多级缓存查询流程是怎样的?(本地 -> 分布式 -> DB)。

  • 本地缓存设多大?过期策略是什么?(回答重点:根据热点数据量和内存监控来定)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 定义缓存管理器,配合Spring的 @Cache 来使用
*
* @return
*/
@Bean("caffeineCacheManager")
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder().
// 设置过期时间,写入后五分钟国企
expireAfterWrite(5, TimeUnit.MINUTES)
// 初始化缓存空间大小
.initialCapacity(100)
// 最大的缓存条数
.maximumSize(200)
);
return cacheManager;
}

@PostConstruct
public void init() {
// 这里借助手动解析配置信息,并实例化为Java POJO对象,来实现代理池的初始化
ProxyCenter.initProxyPool(proxyProperties.getProxy());
}
  • 什么是“缓存击穿”? 如果首页某个热点专栏突然失效,大量请求压向 DB 怎么办?(回答重点:逻辑过期、互斥锁/双检锁)。

  • 如何防止本地缓存把内存撑爆?

    Hard Limit(硬限制): 通过 maximumSizemaximumWeight 严格限制缓存个数/字节大小。一旦触达,立即触发淘汰。

    Soft Values(软引用): 配置 Caffeine 使用 softValues()。这意味着当 JVM 内存极其紧张、即将发生 OOM 之前,GC 会强行回收这些缓存对象来保命。

  • 80ms 是怎么测出来的? 优化前是多少?

    除了 F12 查看 Network 面板的响应时间,我还使用了 JMeter 进行压测,对比优化前后的 P99(99% 的请求耗时)

    优化前后的对比(建议数值):

    • 优化前: 大约在 300ms - 500ms 左右。因为首页侧边栏、推荐位需要多次查询数据库,涉及多次网络 IO。

    • 优化后: 稳定在 80ms 以内。因为侧边栏等高频数据直接命中本地内存,几乎是零 IO 开销。

Caffeine 配置类: 看一下你设置的 initialCapacity(初始容量)、maximumSize(最大容量)和 expireAfterWrite(过期时间)的具体数值。

查询逻辑代码: 确认是否使用了 Cache.get(key, k -> value) 这种自动回源的写法,还是手动写的 if(null) { ... }

消息通知机制: 检查项目中是否有处理“缓存同步”的代码。如果没有,面试时要准备好“如果需要同步,我会怎么做”的方案。

序列化方式: Redis 存的是 JSON 还是 JDK 序列化?这直接影响到响应耗时。

“首页流媒体和专栏属于高频热点读多写少的数据。单纯依赖 Redis 仍会有网络 IO 开销和反序列化耗时。为了极致提升吞吐量,我采用了 L1(本地缓存 Caffeine)+ L2(分布式缓存 Redis) 的架构。

这样,绝大部分请求在 JVM 进程内就能直接返回,避免了网络波动对响应耗时的影响。”

“在查询时,程序先尝试从 Caffeine 中读取。如果缺失,再穿透到 Redis。如果 Redis 也没有,则加锁查询数据库并双写回缓存。 为了保证‘80ms’的指标,我对首页数据做了精简(DTO 瘦身),并使用 Protobuf 或 Jackson 优化了序列化性能。通过 JMeter 压测,在高并发下响应耗时从原来的 300ms+ 稳定下降到了 80ms 以内。”

“多级缓存最大的挑战是数据一致性。在我的方案中,对于一致性要求极高的数据,我采用了 Redis Pub/Sub 机制:当某个实例更新了 DB,会向特定的 Channel 发送通知,其他所有服务实例收到通知后立即失效本地的 Caffeine 缓存,从而确保用户看到的是最新数据。”


项目亮点话术(其实这里是filter,我不知道怎么说)

在登录认证这块,我做了一个全局上下文封装。具体是把 JWT 校验前置到拦截器里,在请求进入 Controller 之前统一解析 token,提取用户 id,然后通过 ThreadLocal 存入 BaseContext。这样在后续 Controller、Service、持久层扩展逻辑里,都可以直接获取当前登录用户,不需要每个接口重复解析 token 或重复查用户信息。
这样做的价值主要有三个:
第一,统一身份获取入口,减少重复代码;
第二,基于线程隔离实现当前请求内的用户身份共享,调用链传递更方便;
第三,降低了业务层对 HttpServletRequest 的依赖,提高了代码可维护性。
当然这套方案的前提是同步请求链路,并且必须在请求结束后及时清理 ThreadLocal,避免线程池复用带来的脏数据问题。

1)你为什么要在登录拦截器里封装全局上下文?

主要是为了解决“当前登录人信息在业务链路里重复获取”的问题。
如果不做这层封装,很多接口都要重复从请求头拿 token、解析 JWT、再提取 userId,甚至有的地方还会再查一次用户信息,这样会带来三类问题:

  • 重复代码多,认证逻辑分散
  • 业务层依赖请求对象,不够解耦
  • 容易出现有的接口校验了,有的接口忘记校验,安全边界不一致

所以我把认证逻辑收敛到拦截器中,把“当前用户是谁”作为一个请求级上下文统一封装。后续代码只要关心业务,不需要关心用户身份是怎么拿到的。

你可以顺带结合项目举例:

比如地址簿查询接口里,直接通过 BaseContext.getCurrentId() 获取当前用户 id,然后查询该用户的地址,而不是在 Controller 中再写一遍 token 解析逻辑。

2)你说“减少重复查询”,具体减少的是什么查询?

更准确地说,这里减少的是“重复的身份解析和用户获取动作”,不一定只是数据库查询。
在这个项目里,JWT 本身已经包含了用户标识,所以很多场景下不需要每次都去数据库查用户,只需要在拦截器里解析一次 token,把用户 id 放到上下文里,后续链路共享即可。
如果某些业务场景确实要拿完整用户信息,也可以在第一次查询后再做请求级缓存,避免同一次请求里多次查库。
所以这个优化点本质上是:统一身份解析 + 请求内共享 + 避免重复获取。

如果你直接说“减少数据库查询”,有些面试官会问你:

你这段代码里不是只把 userId 放到 ThreadLocal 了吗?哪里减少查库了?

所以你最好答得更精准:
“减少重复身份解析,必要时也能减少同一请求内的重复查库。”

3)为什么选拦截器,不选 Filter?

因为这个项目是基于 Spring MVC 的,我这里更关注的是 Controller 层方法调用前的身份校验和上下文注入,所以拦截器更合适。
HandlerInterceptor 能拿到 handler,可以判断是不是 Controller 方法,还能更方便地和 Spring MVC 的执行链结合。
Filter 更偏 Servlet 规范层,适合做编码、跨域、统一日志、最外层请求处理;
而登录鉴权这种和 Controller 访问强相关的逻辑,用拦截器可控性更好。

你可以补一句:

如果项目里需要做更统一、更底层的认证,比如网关层或 Spring Security 体系里,也可以放到 Filter 或 Security Filter Chain 中处理。

4)ThreadLocal 是什么?为什么它能实现线程级共享?

ThreadLocal 不是在线程之间共享数据,而是给“每个线程单独存一份副本”。
当前请求在 Spring MVC 的同步处理模型下,通常会由一个工作线程从头执行到尾,所以我在拦截器里把用户 id 存到当前线程的 ThreadLocal 后,当前请求后续的 Controller、Service、DAO 执行过程中,都可以从这个线程上下文中拿到同一个用户 id。
它的本质是:

  • 数据不是放在线程共享区
  • 而是存在线程对象内部的 ThreadLocalMap
  • key 是当前这个 ThreadLocal 实例,value 是你放进去的数据
    所以它适合保存请求级、线程级的上下文信息,比如当前用户、租户、traceId 等。

5)ThreadLocal 的底层原理是什么?

每个 Thread 对象内部都有一个 ThreadLocalMap
当调用 threadLocal.set(value) 的时候,本质上是以当前 ThreadLocal 实例作为 key,把 value 存到当前线程的 ThreadLocalMap 中。
调用 get() 时,也是从当前线程自己的 ThreadLocalMap 里取值。
因为每个线程都有自己独立的 map,所以线程之间互不干扰。
另外 ThreadLocalMap 里的 key 是弱引用,如果 ThreadLocal 实例被回收了,但 value 还在,就可能产生 value 无法及时释放的问题,所以一般使用完一定要 remove()

如果时间紧,可以说:

ThreadLocal 的核心不是“线程共享”,而是“线程隔离”。每个线程内部维护一份自己的变量副本。

6)为什么说它能减少业务代码耦合?

因为如果不这么做,业务层要拿当前用户,通常有两种写法:

  • 一路传 userId 参数
  • 或者直接依赖 HttpServletRequest
    这两种都会让业务代码和认证细节耦合。
    BaseContext 后,业务层只依赖“当前用户上下文”这个抽象,不关心 token 从哪来、怎么解析,这样职责分层会更清晰。

7)你这个方案有什么风险?

最大的风险是 ThreadLocal 使用不当导致线程池复用时的脏数据问题和内存泄漏风险。
因为 Web 容器线程通常来自线程池,一个请求结束后线程不会销毁,而是复用给下一个请求。
如果上一个请求把用户 id 放进了 ThreadLocal,但没有及时清理,下一个请求复用这个线程时,就有可能读到上一个用户的数据,造成严重的越权问题。
所以正确姿势是:

  • 在请求进入时 set
  • 在请求结束时 remove
  • 通常放在拦截器的 afterCompletion 或过滤器的 finally 中清理

8)为什么必须 remove(),只 set(null) 行不行?

最推荐的是 remove(),而不是简单 set(null)
remove() 会真正把当前线程 ThreadLocalMap 中对应的 entry 清掉;
set(null) 只是把 value 置空,但 entry 还在,长期运行下不够干净,也不利于避免内存问题。
所以规范做法是 try/finally 或请求完成回调里 remove()

9)如果接口里开启了异步线程,ThreadLocal 还有效吗?

默认情况下无效。
ThreadLocal 只对当前线程可见,如果业务处理中切到了其他线程,比如线程池异步任务、@AsyncCompletableFuture,新线程是拿不到原线程里的用户上下文的。
所以这套方案适用于同步请求链路。
如果要跨线程传递上下文,常见方案有:

  • 显式传参,把 userId 往下传
  • 使用 InheritableThreadLocal,但它对线程池场景并不可靠
  • 使用阿里 TTL(TransmittableThreadLocal)这类上下文传递方案
  • 或者在异步任务提交时手动封装上下文

10)那为什么不用 InheritableThreadLocal

InheritableThreadLocal 只是在“创建子线程”时把父线程值拷贝过去。
但在线上项目里,异步任务大多是线程池复用线程,不是每次新建线程,所以它在实际线程池场景下并不能稳定解决上下文透传问题。
因此对于线程池异步场景,我更倾向于显式传参或者使用 TTL 这类专门的方案。

11)你为什么只存用户 id,不直接存整个用户对象?

我更倾向于只存最小必要信息,比如用户 id、员工 id、租户 id,而不是完整用户对象。
原因有三个:

  1. 用户 id 更轻量,减少线程上下文存储负担
  2. 降低对象过期风险,避免把旧的用户状态长期保存在上下文里
  3. 权责更清晰,需要完整用户信息时再按需查询或缓存

这也是为什么这个项目里 BaseContext 存的是 Long 类型,而不是 User 实体。

12)你这个方案和把 userId 一层层传参相比,有什么优缺点?

优点是调用链更简洁,减少很多样板参数,业务代码可读性更高。
缺点是隐式依赖更强,也就是方法签名上看不出它依赖当前用户上下文,需要开发者知道这个约定。
所以我的理解是:

  • 对于“全链路都天然依赖当前登录人”的 Web 请求场景,用 ThreadLocal 很合适
  • 对于公共组件、异步任务、跨系统调用,还是显式传参更稳妥

14)如果 token 失效或者伪造,会怎么处理?

在拦截器里统一做 JWT 校验,校验失败直接返回 401,不进入业务层。
这样业务层默认拿到的都是已通过认证的身份信息。
同时 token 的签名校验依赖服务端密钥,攻击者即便伪造 payload,没有正确签名也无法通过校验。

15)这个方案算不算权限控制?

严格来说,这一层主要解决的是“认证”和“身份传递”,不完全等于“权限控制”。
它回答的是“你是谁”;
权限控制回答的是“你能做什么”。
认证通过后,还需要结合角色、菜单、资源权限做授权判断。
所以这套 ThreadLocal 上下文方案更偏基础设施,为后续权限判断提供统一的身份来源。

追问:你这个实现有没有可以优化的地方?

有两个点可以优化。

第一,ThreadLocal 清理时机。
当前 BaseContext 里已经封装了 removeCurrentId(),但如果拦截器里只 setremove,在 Tomcat 线程池复用场景下会有上下文串用风险。生产环境里我会在 afterCompletion() 统一清理。

第二,当前上下文只存了一个 Long id,对于简单项目够用;如果后续要支持多租户、角色、请求追踪等,我会把它升级成一个 UserContext 对象,但仍然只放必要字段,避免存过重对象。


在这个项目里,我主要落地了基于 Spring Cache + Caffeine 的本地缓存,用于首页和专栏相关的侧边栏等高频、低变更数据,减少重复查询;同时首页通过异步并行聚合文章列表、轮播图、侧边栏和用户信息,来提升整体响应性能。

Redis 在项目中也有使用,但更多承担会话校验和部分状态缓存职责,而不是完整意义上的首页多级缓存体系。

1)你这个项目里到底缓存了什么?

这个项目里明确做了 Caffeine 本地缓存,主要缓存的是首页侧边栏、专栏侧边栏、文章详情侧边栏这类高频访问、更新不频繁的数据。
比如 SidebarServiceImpl 里,queryHomeSidebarList()queryColumnSidebarList()queryArticleDetailSidebarList() 都加了 @Cacheable,缓存管理器是 caffeineCacheManager
所以这个项目的缓存重点不是整页 HTML 或首页文章流,而是这些稳定的推荐/展示型数据。

  • Caffeine:用于本地热点数据缓存
  • Redis:更多用于登录态/JWT session、计数和一些业务状态
    并没有看到统一封装好的“先查 Caffeine、再查 Redis、最后查 DB”的完整多级缓存链路专门服务首页热点数据。
    所以如果面试要讲项目真实情况,我会说这是“本地缓存 + Redis 能力并存”,而不是硬说成完整多级缓存体系。

3)为什么首页能更快?主要靠缓存吗?

这个项目里首页提速不只是靠缓存,更关键的是并行化聚合
IndexRecommendHelper.buildIndexVo() 里把首页数据拆成多个任务并发执行,包括文章列表、置顶文章、轮播图、侧边栏、登录用户信息。
其中侧边栏又叠加了 Caffeine 缓存,所以整体响应时间会下降。
也就是说,这里的优化思路更像是:

  • 对稳定数据做本地缓存
  • 对多个独立数据源做异步并行聚合

4)为什么选 Caffeine,不直接全放 Redis?

因为这里缓存的数据访问频率高、数据量不大、变化也不频繁,先放本地缓存命中最快,少一次网络 IO。
Caffeine 的优点是:

  • JVM 内存命中快
  • 接入 Spring Cache 简单
  • 适合首页侧边栏、推荐位这类热点只读数据
    Redis 更适合做跨实例共享、需要统一状态的一类缓存。
    所以如果只有单机或小规模部署,Caffeine 对这类场景性价比很高。

5)Caffeine 配置是怎么做的?

项目里在 ForumCoreAutoConfig 中定义了 caffeineCacheManager,核心配置是:

  • 写入后 5 分钟过期
  • 初始容量 100
  • 最大条数 200
    然后通过 Spring 的 @Cacheable@CacheEvict 来使用和失效。

6)缓存一致性怎么处理?

这个项目里主要有两种处理方式:

  1. 对文章详情侧边栏,在后台更新文章时通过 @CacheEvict 删除对应缓存
  2. 后台还提供了一个缓存刷新接口,可以手动清空 Caffeine 缓存

所以它采用的是比较轻量的缓存一致性方案:

  • 低频更新场景下,优先用 TTL 兜底
  • 关键写操作时主动失效
  • 管理后台提供手动刷新能力

7)为什么只缓存侧边栏,不缓存首页文章列表?

因为侧边栏数据更新频率低、结构稳定、命中率高,更适合直接缓存。
首页文章列表和分类文章流的变化更频繁,和分页、分类、置顶、发布时间等因素相关,直接缓存整块数据会让 key 设计和失效策略复杂很多。
所以这个项目里先缓存收益更明确、维护成本更低的部分,是一个比较务实的做法。


8)如果让你继续优化成真正的多级缓存,你会怎么做?

如果要往真正的“Caffeine + Redis 多级缓存”演进,我会这样设计:

  • L1:Caffeine,本地热点缓存,承接高频读
  • L2:Redis,跨实例共享缓存
  • L3:DB/服务查询
    查询流程是:先本地,再 Redis,再回源;回源后回填两级缓存。
    同时配套:
  • 不同业务 key 规范
  • TTL + 随机过期时间,防止雪崩
  • 热点 key 互斥加载,防止击穿
  • 变更时删 Redis 并广播/消息通知清本地缓存
    但这部分属于“我会怎么扩展”,不是当前项目里已经完整落地的部分。

9)你说首页 80ms,有数据支持吗?

如果严格按项目代码来讲,我不会把“80ms”说成一个确定结论,因为代码仓库里我没有看到明确的压测报告或监控数据。
我更倾向于说:
“通过本地缓存 + 首页并行聚合,能够显著降低首页接口的平均响应耗时。”
如果面试官追问具体数字,我会说明那需要结合压测环境、机器配置、数据量和缓存命中率来给出。

这句很重要,避免被追着问压测细节。


你可以直接背的“项目版回答”

这个项目里缓存优化是做过的,但更准确地说,是基于 Spring Cache + Caffeine 的本地缓存,而不是完整落地的 Caffeine + Redis 多级缓存体系。
具体缓存的重点在首页侧边栏、专栏侧边栏、文章详情侧边栏这类高频但低变更数据,通过 @Cacheable 提高命中率,减少重复查询;同时首页数据聚合用了异步并行,把文章列表、轮播图、侧边栏和用户信息并发拉取,所以首页响应性能提升不只是靠缓存。
Redis 在项目里也有使用,但更多是登录态、session/JWT 校验和一些状态型数据,不是专门作为首页热点数据的二级缓存。
如果要把这块包装成真正的多级缓存能力,还需要补充 Redis 回填、本地缓存失效广播、热点 key 保护这些机制。


通过Redis 的 zset 实现用户活跃度排行,并通过幂等防止重复添加活跃度,通过Spring的Event和 Listenter 来触发活跃度更新。

你可以直接背这一版,比较自然:

我们项目里用户活跃度排行是用 Redis 的 ZSet 做的。
设计上把 userId 作为 ZSet 的 member,把活跃分作为 score,然后分别维护了日榜和月榜两个 key,比如当天一个 key、当月一个 key。

当用户发生访问、点赞、收藏、评论、发文章、关注这类行为时,服务层会先把这次行为抽象成一个 ActivityScoreBo,根据行为类型算出本次应该加多少分或减多少分,然后调用 Redis 的 ZINCRBY 去更新该用户在日榜和月榜中的分值。

另外项目里不是直接裸写 ZSet,而是又用 Redis Hash 做了一层幂等控制。Hash 里会记录“这个用户今天这个动作有没有加过分”,比如某篇文章是否已经点赞加分过。这样可以避免重复点击、重复请求导致分数被重复累计。

查询排行榜时,就是从对应的 ZSet 里拿 TopN 用户和分数,再批量查用户的昵称头像等信息做组装。查询单个用户排名也是直接从 ZSet 里查 rank 和 score。

这个方案的优点是更新快、查询快、实时性强,很适合排行榜场景。
如果要继续优化的话,我会关注幂等逻辑的原子性问题,因为现在是先查 Hash 再写 Hash 再更新 ZSet,极端并发下可能有竞态,比较好的做法是用 Lua 脚本把这几个 Redis 操作做成原子执行。

如果问:为什么不用 MySQL 排行?

你答:

MySQL 更适合持久化和复杂查询,但不适合高频实时排序。活跃度这种持续变动的实时排行榜,用 Redis ZSet 的增量更新和按分值排序会更高效。

如果问:为什么还要 Hash,不直接 ZSet 就行?

你答:

ZSet 只解决“排序”和“累加分数”,不解决“同一个动作是否重复记分”的问题,所以需要额外用 Hash 做幂等控制。

如果问:为什么同时维护日榜和月榜,不临时聚合?

你答:

临时聚合成本高,而且会增加查询延迟。提前维护两套榜单,查询时可以直接返回,适合高并发读场景。

我去临时聚合是什么。


利用AOP切面监听点赞、评论等用户行为事件,异步将通知消息写入RabbitMQ消息队列,消费者服 务再将通知消息写入

坏了,这里感觉没有用aop,用的应该是监听事件吧。。。

  • 为什么用 AOP 而不是直接在 Service 里写发送代码?(回答重点:解耦、非侵入性、代码复用)。
  • 切面是怎么定义的?(用的是 @Around 还是 @AfterReturning?建议用 AfterReturning,只有操作成功了才发通知)。
  • 如果点赞成功了,但消息发送 RabbitMQ 失败了怎么办?高频考点:分布式事务一致性)。
  • 如何保证消息不丢失?(回答重点:生产者确认 confirm、交换机/队列持久化、消费者手动 ACK)。
  • 如果 RabbitMQ 挂了,点赞功能还能用吗?(回答重点:降级策略,异步发送不能影响主业务流)。
  • 如何防止重复消费(幂等性)?(如果网络波动导致消息重发,用户会不会收到两条一样的通知?)。
  • 为什么要异步?同步写数据库不行吗?(回答重点:点赞、评论属于高频操作,同步写通知库会拖慢主逻辑响应速度)。
  • 消费者服务压力太大处理不过来怎么办?(回答重点:限流、削峰填谷、增加消费者节点)。

AOP 切面类: 找一下 @Pointcut 表达式,你是切在具体的注解上(如 @NotifyUpdate)还是切在特定的方法名上?

消息体格式: 存入 RabbitMQ 的是 JSON 字符串还是对象序列化?(建议是 JSON,跨语言且易排查)。

RabbitMQ 配置: 检查是否有 CachingConnectionFactory 的配置,是否开启了 publisher-confirm-type: correlated

消费端幂等逻辑: 消费者写数据库前,有没有查一下这张通知表里是否已经存在相同的 business_id(如点赞记录 ID)?

“在开发点赞和评论功能时,我发现这些操作属于核心高频路径,而‘发送通知’属于次要逻辑。如果同步执行,会增加主业务的响应耗时。

此外,如果通知逻辑报错,不应该导致点赞失败。所以我决定通过 AOP 切面 将通知逻辑剥离,利用 RabbitMQ 实现异步化解耦。”

“我自定义了一个注解,标记在需要触发通知的方法上。切面类通过 @AfterReturning 拦截这些请求,将用户信息、行为类型、目标 ID 封装成 JSON 消息,推送到 RabbitMQ 的交换机中。

消费者服务独立运行,监听队列并根据消息类型异步写入 notification 表。这样主业务线程在点赞完成后立即返回,响应耗时大幅降低。”

“为了确保消息不丢失,我开启了 RabbitMQ 的生产者确认机制。如果 MQ 宕机发送失败,我会捕获异常并进行本地日志记录或尝试重试。

在消费端,我采用了手动 ACK 模式,确保消息真正写入数据库后才从队列移除。同时,为了防止重复通知,我在数据库层面设置了唯一索引或在代码中做了幂等校验,确保同一个行为不会产生重复通知。”

关于事务的“坑”:

  • 风险:如果你在点赞事务还没提交(commit)前就把消息发出去了,万一事务回滚了,用户却收到了通知,这就是数据不一致。
  • 对策:你可以提到利用 TransactionSynchronizationManager.registerSynchronization,确保在事务成功提交后再发送 MQ 消息。

消息堆积怎么办?

  • 提到你会设置消息的 TTL(过期时间),或者配置 死信队列(DLX) 来处理那些无法正常投递的异常消息。

为什么不用 Spring 内部的 ApplicationEvent

  • 回答:Spring Event 是进程内的,如果服务重启,内存里的事件就丢了;RabbitMQ 是独立的中间件,支持持久化和横向扩展,更适合分布式环境。

“如果你的消费者服务挂了 10 分钟,这期间产生的 1 万条点赞通知会消失吗?重启后会发生什么?” (答案:不会消失,消息会堆积在磁盘持久化的队列里,重启后消费者会以‘匀速’重新开始消费,这就是削峰填谷。)


基于WebSocket 实现前端和后端之间的长连接通信通道,并结合DeepSeek大模型的StreamAPI实现

流式响应返回,只要后端有新的内容到达,前端就即时将文本逐步拼接显示。

我利用 WebSocket 建立了前后端的双向长连接,主要为了解决大模型回复耗时较长、用户等待焦虑的问题。
具体的实现逻辑是:后端利用 WebClient 异步监听 DeepSeek 的响应流(SSE),并采用‘边收边发’的策略,将解析出的 delta content 立即通过 WebSocket Session 推送。
为了保证体验,我们还加入了无感 Token 刷新机制和异步线程池优化,确保即使在流式传输这种长生命周期请求中,用户的身份信息和系统稳定性也能得到保障。”

  • Q:如果 WebSocket 突然断了,AI 还在后台跑,怎么处理?
    • A:我们会通过 WebSocketHandlerafterConnectionClosed 回调,第一时间清理该 Session 对应的后台任务和 API 请求流,防止资源浪费。
  • Q:流式传输对服务器内存压力大吗?
    • A:因为我们用了 WebClient 的响应式非阻塞模型,数据是‘流过’服务器而不是‘存在’服务器,所以对内存的占用极低。

“我设计这套方案的核心目标是高可用一致性

针对大文件,我们采用 MD5 校验 + MinIO 分片上传,利用 Redis Bitmap 的位运算特性实现极低开销的断点续传。

考虑到后续的 PDF 解析(Tika)和向量化(Embedding)是计算密集型任务,我引入了 Kafka 作为缓冲层。这样做不仅实现了文件上传与复杂处理逻辑的解耦,还利用 Kafka 的持久化机制保证了处理链路的可追溯和高容错。

即使在处理过程中某个节点宕机,由于分片状态记录在 Redis、原始文件在 MinIO、处理任务在 Kafka,系统可以实现无损的任务恢复。”

  • Q:如果 Kafka 里的消息处理失败了,导致向量数据库里缺数据,你怎么办?
    • A:我们会利用 Kafka 的死信队列(DLN)记录失败任务,并配合定时任务对比 MinIO 文件库与向量库的数量,实现最终一致性。

“我实现了一套 ‘关键词 + 语义’的双引擎搜索方案

首先,利用 IK 分词器 处理中文文档的细粒度拆解,保证了对专业术语的搜索精度。 其次,集成 阿里 Embedding 模型,利用 ES 的 KNN 向量检索 实现对模糊语义的捕捉。

为了提升 RAG 的回答质量,我重点优化了检索召回率。通过在 ES 侧结合关键词过滤防止结果跑偏,并利用 BM25 算法进行重排序,将召回结果进行二次筛选。这种‘双路并行、加权融合’的方法,相比单一的向量检索,将知识库的回复准确率提升了约 30% 左右(这是一个参考数据,体现你的结果导向)。”

  • Q:ES 的向量搜索和专门的向量数据库(如 Milvus)相比有什么优缺点?
    • A:ES 的优势在于‘一站式解决方案’。它能极其方便地做‘混合查询’(在一个 DSL 语句里同时写 filter 和 knn),不需要跨数据库同步数据,维护成本低。对于我们这种百万级规模的知识库,ES 的性能完全足够。
  • Q:切片(Chunking)大小你是怎么定的?
    • A:我们通常设在 500-800 Token 左右,并设置 10% 的重叠度(Overlap),防止关键信息在切片处被切断。

当你在 new TypeReference<...>() 后面加上 {} 时,你实际上是写了一个继承自 TypeReference 的临时子类,只是这个子类没有名字。

匿名内部类方式:当你用 {} 创建子类时,Java 会把父类的泛型信息(即那个复杂的嵌套结构)保存在这个子类的 “字节码” 里。

Jackson 的骚操作:Jackson 进入这个匿名内部类,通过反射(Reflection)读取它父类的泛型参数,从而惊叹道:“喔!原来你要的是一个装满 Map 的 List 啊!”

这种技术在 Java 里有个专业名词:超级类型令牌 (Super Type Tokens)。

问题

关于AOP

  1. 如果你在技术派的源码中搜 MdcUtil 的话,可以在 ReqRecordFilter 中找得到,顾名思义,该类是对请求的一个过滤器,会在每个请求中加上全链路的 traceid。

    项目中一整个MdcDot注解都被用来记录方法用时,那么reqrecord中的记录又是在干什么?他有一个.log文件的记录,同时还把这一次的请求时间输出了。

    我想一个适用于普通方法,而req的这个是在filter里专门记录请求的时间,可能和那个方法不兼容?

    还有一个问题,两个方法都用的mdcutil里的方法,这个traceid没有覆盖的可能吗?

    1. MdcDot 与 ReqRecord 的区别?traceId 会不会互相覆盖?

    你已经大致猜对了,但我帮你补充成一个完整逻辑:

    1)MdcDot 用来记录方法执行耗时

    • 这是一个 AOP 注解(作用在某个方法上)
    • AOP 拦截方法 → 进入前记录时间 → 退出后输出日志
    • 一般属于业务级别的方法耗时监控(精确到某个 service 方法)

    例如:
    UserService.getUserById() 花了 230ms
    OrderService.create() 花了 1.2s

    这些属于应用内部的性能监控。

    2)ReqRecord(你看到的 req 处理)是 Filter 层的请求级日志

    这段逻辑在 Filter 或 Interceptor 里:

    • 进入请求:建立 traceId、记录用户信息、记录路径、IP、UA
    • 完成请求:统一输出一次完整请求日志(写入 .log)

    这是每次 HTTP 请求的入口 / 出口监控
    也就是“这次 API 调用持续了多久”。

    例如:
    GET /api/user/info cost=156ms
    POST /order/create cost=1220ms

    该日志是更偏向运维监控、流量统计、接口质量监控

    3)两个都用 MdcUtil,会不会覆盖 traceId?

    不会。

    原因:

    • traceId 是在 filter 入口时统一设置的
    • AOP 方法执行时只会使用这个 traceId,不会重新生成
    • MdcUtil.addTraceId() 基本格式就是:“如果 MDC 中没有 traceId 才生成”

    所以一个请求有一个 traceId,所有方法耗时都共享它。

  2. 这个项目细分了好多类,他还有一个Dao,专门和service区分开来了。@Respository和@Service 的区别只是标记不一样吗

    2. Repository 与 Service 的区别?什么时候分层?

    不是标签不同而已。

    Repository(DAO)职责

    只负责与数据库交互

    • CRUD
    • 自定义 SQL
    • 查询分页
    • 统计 SQL

    Repository/mapper 层不包含任何业务逻辑。

    Service 的职责

    封装业务逻辑

    • 参数校验
    • 领域逻辑组合
    • 调用多个 DAO / RPC
    • 事务控制
    • 缓存控制

    什么时候需要分?

    当你有以下需求时必须分:

    • 业务需要组合多个数据库表的操作
    • 一个请求涉及多个操作(比如先查用户再查订单)
    • 需要保证事务(@Transactional)
    • 需要隐藏数据库细节(controller 不应该直接写 SQL)

    你之前的项目没有 DAO 层,是因为业务太简单

    所以说,之前那个项目有service层用其他service的情况,这种情况其实应该使用dao吗?

  3. 用service CRUD 和Mapper CRUD 的区别在?本来serviceimpl这个继承的类里就要求mapper继承了basemapper的,那用哪个有说法吗?我想了想可能是因为:用service的肯定是controller 层 ,controller层不可能去调用mapper层的东西的,即使调用的service其实本质上还是mapper,而dao(service)层就可以做基本CURD做不到的事情?

    3. 为什么 MyBatis-Plus 要提供 Service CRUD?和直接在 Service 调 Mapper 有什么区别?

    你问的这个问题非常关键:
    “既然 Controller 能调 Service,那为什么 Service 不自己写 CRUD,而要依赖 MP 提供的 ServiceImpl 的 CRUD?”

    我拆成 3 点,你看了就懂了。


    ① MyBatis-Plus 的 Service CRUD 是通用业务服务层的模板

    MP 的设计理念:

    • Mapper 层只负责数据库 CRUD(原子、低级别操作)
    • Service 层需要在 Mapper 的基础上补充业务流程逻辑
    • 绝大多数业务都会需要标准化的 CRUD(分页、按条件查询、批量删除……)

    因此 MP 提供了 IService + ServiceImpl,目的是:

    • 你不用每个项目都重复写:list、getById、removeById、save、update……
    • 这些方法自动配合 MP 的 QueryWrapper、分页插件、自动填充等
    • 代码统一规范、减少重复性工作

    也就是说,它做的是“中间层简化”和“业务层能力增强”。


    ② 你完全可以不继承 IService,但你会多写大量重复代码

    假设你不用 MyBatis-Plus 的 IService,你会写成这样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class UserService {
    @Autowired
    private UserMapper mapper;

    public User getById(Long id) {
    return mapper.selectById(id);
    }

    public List<User> list() {
    return mapper.selectList(null);
    }

    public void save(User u) {
    mapper.insert(u);
    }

    public void update(User u) {
    mapper.updateById(u);
    }

    public void delete(Long id) {
    mapper.deleteById(id);
    }
    }

    这是一堆重复工作,而且不同开发者写出的接口风格可能完全不一致。

    MP 帮你统一成:

    1
    2
    @Service
    public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {}

    然后你可以直接用:

    1
    2
    3
    4
    userService.getById(id);
    userService.list();
    userService.save(entity);
    userService.removeById(id);

    你几乎不用写额外代码,即可保证所有项目统一风格。

  4. 为什么这里要用@Param注释?不加不可以吗?这是mybtis就有的还是plus才有的?这样的话在yml设置里开启驼峰命名的意义在?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public interface UserMapper extends BaseMapper<UserDO> {
    /**
    * 根据三方唯一id进行查询
    *
    * @param accountId
    * @return
    */
    @Select("select * from user where third_account_id = #{account_id} limit 1")
    UserDO getByThirdAccountId(@Param("account_id") String accountId);
    }

    情况1:编译器保留了参数名(默认情况自 JDK8 后大多数 IDE 会开启)

    MyBatis 可以直接识别:

    1
    UserDO getByThirdAccountId(String accountId);

    此时可以写:

    1
    #{accountId}

    无需 @Param。


    情况2:项目没有启用参数名保留功能(某些环境未开启 -parameters)

    MyBatis 拿到的参数名会变成:

    1
    arg0, arg1, arg2...

    你的方法:

    1
    UserDO getByThirdAccountId(String accountId)

    在运行期可能变成:

    1
    arg0

    这时你 SQL 写:

    1
    #{accountId}

    就会报错:找不到 accountId。

    ==> 因此为了防止环境不一致,团队规范通常建议:多参数和注解 SQL 要使用 @Param。

  5. TableId(type = IdType.AUTO),在数据库本来就设置了自增的情况下,还用这个注解让他在插入的时候自增其实没什么意义吧?哦,等等,它的数据库都没有设置约束,约束全在mybatis里,这样的好处在?

    5. @TableId(type = IdType.AUTO) 为何数据库不建约束?

    MP 默认通过注解控制主键策略,数据库里不建约束有两个好处:

    1)跨数据库兼容

    MySQL, PostgreSQL, Oracle 都有不同的自增写法
    MP 用注解帮你屏蔽差异。

    2)方便迁移(比如切换到雪花算法)

    你只改 Java 注解,不需要改表结构。

    项目越大越喜欢用 Java 控制,而非 DBA 控制。

  6. 为何这个是用的传统渲染页面?

  7. ```java
    @Controller
    public class IndexController extends BaseViewController {

     @Autowired
     private IndexRecommendHelper indexRecommendHelper;
    
     @GetMapping(path = {"/", "", "/index", "/login"})
     public String index(Model model, HttpServletRequest request) {
         String activeTab = request.getParameter("category");
         IndexVo vo = indexRecommendHelper.buildIndexVo(activeTab);
         model.addAttribute("vo", vo);
         return "views/home/index";
     }
    

    }

    1
    2
    3
    4
    5
    6
    7
    8
    9



    > ## 为什么不直接在 Controller 调 Service?
    >
    > 这是你问得最**专业**的点。
    >
    > ### ❌ 反模式(Controller 直接堆 Service)
    >

    ArticleService
    TagService
    UserService
    RecommendService

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    Controller 会变成:

    - 巨大
    - 难维护
    - 逻辑混乱
    - 改一处牵一堆

    ------

    ### ✅ 这里采用的是「Helper / Facade 层」

    Controller

    IndexRecommendHelper ← 聚合逻辑

    多个 Service

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33

    `IndexRecommendHelper` 的职责是:

    > **“为某一个页面 / 场景,组装完整的业务数据”**

    ------

    ## 五、这样设计的好处(非常重要)

    ### 1️⃣ Controller 极度干净

    Controller 只做 3 件事:

    1. 接收请求
    2. 调用场景服务
    3. 返回视图

    ------

    ### 2️⃣ Service 保持“单一业务职责”

    - ArticleService:文章
    - TagService:标签
    - UserService:用户

    不污染 Service 层

    ------

    ### 3️⃣ 页面级逻辑可以复用

    今天是:

    buildIndexVo()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44

    明天可能是:

    - PC 首页
    - 移动端首页
    - 小程序首页

    **Helper 可以复用,Controller 各自不同**

    ------

    ### 4️⃣ 非常适合「复杂页面」

    首页通常是:

    - 数据多
    - 来源多
    - 规则多
    - 经常改

    Helper / Facade 是最合理的位置。

    ------

    ## 六、你可以这样理解它的角色

    | 层级 | 角色 |
    | --------------- | ------------------ |
    | Controller | HTTP + 路由 + 视图 |
    | Helper / Facade | 页面级业务编排 |
    | Service | 单一领域业务 |
    | VO | 页面数据模型 |

    ------

    ## 七、一句话总结(面试级)

    > 这段代码是一个 **MVC 页面 Controller**,
    > 它不直接调用 Service,而是通过 Helper 聚合首页所需的业务数据,
    > 最终将 VO 注入 Model 并返回视图路径。
    > 这种设计降低了 Controller 复杂度,提高了页面逻辑的可维护性和复用性。

    Spring MVC 的流程是:

    Controller
    ↓ 返回 “views/home/index”
    ViewResolver
    ↓ 拼接前缀 + 后缀
    模板引擎(Thymeleaf / JSP)
    ↓ 渲染 HTML
    浏览器收到 HTML

    1
    2
    3

    例如(常见配置):

    spring.thymeleaf.prefix=classpath:/templates/
    spring.thymeleaf.suffix=.html

    1
    2
    3

    那么:

    return “views/home/index”;

    1
    2
    3

    最终渲染的是:

    classpath:/templates/views/home/index.html

    1
    2
    3
    4
    5



    ## 1️⃣ template.execute(...) —— 为什么要这么写?

    return template.execute(new RedisCallback>>() {

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48

    ### 这是在干什么?

    - `template` 是 `RedisTemplate`
    - `execute` 允许你:
    - **直接拿到底层 RedisConnection**
    - 使用 Redis 的原生命令(性能更好、功能更全)

    ### 为什么不用 `redisTemplate.opsForZSet()`?

    因为:

    - `zRangeWithScores` 在某些版本/泛型下不好用
    - 直接用 `RedisConnection` **最稳定、最贴近 Redis 原生命令**

    👉 **这是偏“底层工具类”的写法**





    | 数据结构 | 核心能力 | 最典型用途 |
    | -------- | ----------- | -------------- |
    | String | 单值 / 计数 | 缓存、计数、锁 |
    | List | 有序 | 队列、历史 |
    | Set | 去重 | 白名单、点赞 |
    | ZSet | 排序 | 排行榜 |
    | Hash | 结构化 | 对象缓存 |



    ```java
    @Test
    public void testExecute() {
    // 使用 execute 方法执行 Redis 命令
    redisTemplate.execute(new RedisCallback<Object>() {
    @Override
    public Object doInRedis(RedisConnection connection) throws DataAccessException {
    // 执行 Redis 命令,例如 set 和 get 命令
    connection.set("itwanger".getBytes(), "沉默王二".getBytes());
    byte[] value = connection.get("itwanger".getBytes());
    String strValue = new String(value);
    // 输出获取到的值
    System.out.println(strValue);
    return null;
    }
    });
    }

why cafeine not redis

你这个问题问得非常深刻,直戳架构设计的核心。

通常我们认为“全局共享”的数据该放 Redis,“用户私有”的数据才放本地。但在这个 ChatGPT 场景下,作者使用 Caffeine(本地缓存) 而不是 Redis,其实是基于 “有状态的连接”“内存对象” 这两个特殊原因。


1. 为什么不能放 Redis?(核心:Redis 存不下“活的”对象)

这是最关键的技术限制。

  • Redis 存的是“死数据”:Redis 只能存字符串、数字、JSON。你可以把用户的聊天记录存进去。
  • ChatGPT 对象是“活的”:在代码里,ChatGPTChatGPTStream复杂的 Java 对象。它们内部包含了:
    • 网络连接池(OkHttpClient)
    • 当前的代理配置(Proxy)
    • 底层的事件监听器(EventListener)

问题所在:你没法把一个正在运行的 OkHttpClient 连接序列化成 JSON 存进 Redis,然后再从 Redis 读出来恢复成一个“活”的连接。

所以,这些“工具类对象”必须保存在 JVM 进程的内存里,也就是 Caffeine


2. 这里的 cacheStream 到底存了什么?

看这段代码:

Java

1
public LoadingCache<ImmutablePair<Long, AISourceEnum>, ImmutablePair<ChatGPT, ChatGPTStream>> cacheStream;
  • Key用户ID + 模型类型
  • ValueChatGPT 对象 + 流式请求对象

它的逻辑是:

当用户 A 发起提问时,系统先去 Caffeine 找:“我之前给用户 A 创建过那个带代理、带 API Key 的 Client 对象吗?”

  • 如果有,直接复用这个对象发请求。
  • 如果没有,当场 new 一个,并根据用户 ID 绑定一个固定的代理,然后存入 Caffeine。

3. 本地缓存 vs Redis 的博弈

在这个特定的 AI 插件场景下,用本地缓存有几个好处:

维度 本地缓存 (Caffeine) 分布式缓存 (Redis)
存储内容 Java 实例对象 (包含连接池等) 序列化数据 (JSON/Byte)
访问速度 极快(纳秒级,直接读内存) 快(毫秒级,需要网络 IO)
一致性 弱(A 服务器创建的对象 B 看不见) 强(全集群共享)

为什么这里不需要一致性?

即使你的论坛部署了 2 台服务器,用户第一题在 A 服务器问,第二题路由到了 B 服务器。

  • 在 B 服务器上,Caffeine 找不到记录,会重新为该用户 new 一个 Client。
  • 虽然多了一次 new 的开销,但对于 ChatGPT 这种长耗时的请求来说,这几毫秒的初始化开销根本不算什么。

4. 这里的“用户本地”是个误解

我们要区分两个概念:

  • 用户的浏览器本地:存 Cookie, LocalStorage。
  • 服务器的进程本地:这就是 Caffeine 所在的地方。

所以,这个缓存依然是在服务端的,只是它存在于运行程序的内存里,而不是独立的 Redis 数据库里。


总结

作者这么写,是因为他需要缓存的是 “已经配置好的、可以直接发请求的工具对象”

  1. 为了性能:不用每次请求都重新解析配置、重新构建 HTTP 客户端。
  2. 为了“代理粘滞”:通过固定用户与 Client 对象的对应关系,确保同一个用户的连续对话 IP 相对稳定。

一句话总结:

Redis 用来存“聊了什么”(消息内容),而 Caffeine 用来存“怎么去聊”(带代理和 Key 的连接对象)。

你想看看这个 cacheStream 的过期时间设置吗?

代码里写了 expireAfterWrite(300, TimeUnit.SECONDS),这意味着如果一个用户 5 分钟没说话,这个带代理的连接对象就会被销毁,释放服务器内存。这种自动化管理也是选择 Caffeine 的原因之一。

3. 为什么“只能靠 Cookie”?

因为 Cookie 是 HTTP 协议的一部分,它是跟着请求一起飞到服务器的;而 localStorage浏览器端 JS 对象,它只能静静地躺在浏览器里,等 JS 去唤醒它。

  • 如果用 localStorage: 服务器在渲染页面时,根本不知道你登录了没。它只能先给你一个“未登录”的静态页面,等页面到了浏览器、JS 跑起来了,再闪烁一下变成“已登录”。用户体验会很差(也就是常说的“白屏”或“闪烁”)。
  • 如果用 Cookie: 服务器在发货前就识别了你的身份。你打开网页的一瞬间,看到的就是完整的、带有你头像和名字的私人定制页面。

image-20260325203052156

我草吓哭了

  • 构建工具:后端(Maven、Gradle)、前端(Webpack、Vite)
  • 单元测试:Junit
  • 开发框架:SpringMVC、Spring、Spring Boot
  • Web 服务器:Tomcat、Caddy、Nginx
  • 微服务:Spring Cloud
  • 数据层:JPA、MyBatis、MyBatis-Plus
  • 模板引擎:thymeleaf
  • 容器:Docker(镜像仓库服务 Harbor、图形化工具 Portainer)、k8s、Podman
  • 分布式 RPC 框架:Dubbo
  • 消息队列:Kafka(图形化工具 Eagle)、RocketMQ、RabbitMQ、Pulsar
  • 持续集成:Jenkins、Drone
  • 压力测试:Jmeter
  • 数据库:MySQL(数据库中间件 Gaea、同步数据 canal、数据库迁移工具 Flyway)
  • 缓存:Redis(增强模块 RedisMod、ORM 框架 RedisOM)
  • nosql:MongoDB
  • 对象存储服务:minio
  • 日志:Log4jLogbackSF4JLog4j2
  • 搜索引擎:ES
  • 日志收集:ELK(日志采集器 Filebeat)、EFK(Fluentd)、LPG(Loki+Promtail+Grafana)
  • 大数据:Spark、Hadoop、HBase、Hive、Storm、Flink
  • 分布式应用程序协调:Zookeeper
  • token 管理:jwt(nimbus-jose-jwt)
  • 诊断工具:arthas
  • 安全框架:Shiro、SpringSecurity
  • 权限框架:Keycloak、Sa-Token
  • JSON 处理:fastjson2、JacksonGson
  • office 文档操作:EasyPoi、EasyExcel
  • 文件预览:kkFileView
  • 属性映射:mapStruct
  • Java 硬件信息库:oshi
  • Java 连接 SSH 服务器:ganymed
  • 接口文档:Swagger-ui、Knife4j、Spring Doc、Torna、YApi
  • 任务调度框架:Spring Task、Quartz、PowerJob、XXL-Job
  • Git 服务:Gogs
  • 低代码:LowCodeEngine、Yao、Erupt、magic-api
  • API 网关:Gateway、Zuul、apisix
  • 数据可视化(Business Intelligence,也就是 BI):DataEase、Metabase
  • 项目文档:Hexo、VuePress
  • 应用监控:SpringBoot Admin、Grafana、SkyWalking、Elastic APM
  • 注解:lombok
  • jdbc 连接池:Druid
  • Java 工具包:hutool、Guava
  • 数据检查:hibernate validator
  • 代码生成器:Mybatis generator
  • Web 自动化测试:selenium
  • HTTP 客户端工具:Retrofit
  • 脚手架:sa-plus

CATALOG
  1. 1. 在登录拦截器中封装全局上下文,基于ThreadLocal实现线程级用户身份共享,减少重复查询
  2. 2. 构建Caffeine+Redis 多级缓存体系,提升首页流媒体、专栏等高频热点数据的访问吞吐能力,首页响 应耗时降低至80ms以内
  • 项目亮点话术(其实这里是filter,我不知道怎么说)
    1. 1. 1)你为什么要在登录拦截器里封装全局上下文?
    2. 2. 2)你说“减少重复查询”,具体减少的是什么查询?
    3. 3. 3)为什么选拦截器,不选 Filter?
    4. 4. 4)ThreadLocal 是什么?为什么它能实现线程级共享?
    5. 5. 5)ThreadLocal 的底层原理是什么?
    6. 6. 6)为什么说它能减少业务代码耦合?
    7. 7. 7)你这个方案有什么风险?
    8. 8. 8)为什么必须 remove(),只 set(null) 行不行?
    9. 9. 9)如果接口里开启了异步线程,ThreadLocal 还有效吗?
    10. 10. 10)那为什么不用 InheritableThreadLocal?
    11. 11. 11)你为什么只存用户 id,不直接存整个用户对象?
    12. 12. 12)你这个方案和把 userId 一层层传参相比,有什么优缺点?
    13. 13. 14)如果 token 失效或者伪造,会怎么处理?
    14. 14. 15)这个方案算不算权限控制?
    15. 15. 追问:你这个实现有没有可以优化的地方?
    16. 16. 1)你这个项目里到底缓存了什么?
    17. 17. 3)为什么首页能更快?主要靠缓存吗?
    18. 18. 4)为什么选 Caffeine,不直接全放 Redis?
    19. 19. 5)Caffeine 配置是怎么做的?
    20. 20. 6)缓存一致性怎么处理?
    21. 21. 7)为什么只缓存侧边栏,不缓存首页文章列表?
    22. 22. 8)如果让你继续优化成真正的多级缓存,你会怎么做?
    23. 23. 9)你说首页 80ms,有数据支持吗?
  • 你可以直接背的“项目版回答”
    1. 1. 通过Redis 的 zset 实现用户活跃度排行,并通过幂等防止重复添加活跃度,通过Spring的Event和 Listenter 来触发活跃度更新。
      1. 1.1. 如果问:为什么不用 MySQL 排行?
      2. 1.2. 如果问:为什么还要 Hash,不直接 ZSet 就行?
      3. 1.3. 如果问:为什么同时维护日榜和月榜,不临时聚合?
    2. 2. 利用AOP切面监听点赞、评论等用户行为事件,异步将通知消息写入RabbitMQ消息队列,消费者服 务再将通知消息写入
    3. 3. 基于WebSocket 实现前端和后端之间的长连接通信通道,并结合DeepSeek大模型的StreamAPI实现
  • 问题
    1. 1. 关于AOP
  • 1. MdcDot 与 ReqRecord 的区别?traceId 会不会互相覆盖?
    1. 1. 1)MdcDot 用来记录方法执行耗时
    2. 2. 2)ReqRecord(你看到的 req 处理)是 Filter 层的请求级日志
    3. 3. 3)两个都用 MdcUtil,会不会覆盖 traceId?
  • 2. Repository 与 Service 的区别?什么时候分层?
    1. 1. Repository(DAO)职责
    2. 2. Service 的职责
    3. 3. 什么时候需要分?
  • 3. 为什么 MyBatis-Plus 要提供 Service CRUD?和直接在 Service 调 Mapper 有什么区别?
    1. 1. ① MyBatis-Plus 的 Service CRUD 是通用业务服务层的模板
    2. 2. ② 你完全可以不继承 IService,但你会多写大量重复代码
      1. 2.1. 情况1:编译器保留了参数名(默认情况自 JDK8 后大多数 IDE 会开启)
      2. 2.2. 情况2:项目没有启用参数名保留功能(某些环境未开启 -parameters)
  • 5. @TableId(type = IdType.AUTO) 为何数据库不建约束?
    1. 1. 1)跨数据库兼容
    2. 2. 2)方便迁移(比如切换到雪花算法)
  • why cafeine not redis
    1. 1. 1. 为什么不能放 Redis?(核心:Redis 存不下“活的”对象)
    2. 2. 2. 这里的 cacheStream 到底存了什么?
    3. 3. 3. 本地缓存 vs Redis 的博弈
    4. 4. 4. 这里的“用户本地”是个误解
    5. 5. 总结
    6. 6. 3. 为什么“只能靠 Cookie”?
  • 我草吓哭了