互动问答系统实战:两级评论模型、ES 搜索集成、Caffeine 多级缓存全记录

前面写了几篇优惠券、学习进度的博客,这篇切到互动问答系统。业务本身不复杂------用户提问、回答、评论、点赞。但里面藏了几个我觉得很有意思的技术点:怎么把"无限叠楼"的社交场景简化成两级数据模型;管理端按课程名称搜索问题(问题表根本没这个字段)怎么办;Caffeine 本地缓存为什么比 Redis 还快;分页一次查询要组装用户、课程、章节三种远程数据要怎么不 N+1。这篇把这几块一次讲完。

一、无限叠楼的两级简化:评论系统的表设计

问答业务的核心是"评论"。看起来复杂,其实规则很简单:任何人可以回答问题、可以评论回答、也可以评论别人的评论,理论上无限嵌套------微博的评论区就是这种形态。

如果按真正的无限递归设计,表结构会需要 parent_id 自关联,查询时要递归 CTE 或者应用层多次查库拼树,性能非常差。项目里的产品需求做了一个关键简化:页面渲染只分两层

  • 第一层叫"回答",直接挂在问题下面
  • 第二层叫"评论",不管是评论回答、还是评论评论,都归到同一层

这个简化直接改变了表设计。看评论表:

sql 复制代码
CREATE TABLE interaction_reply (
  id bigint NOT NULL COMMENT '主键',
  question_id bigint NOT NULL COMMENT '所属问题id',
  answer_id bigint DEFAULT 0 COMMENT '上级回答id(0=直接回答)',
  user_id bigint NOT NULL COMMENT '回复者id',
  content varchar(255) NOT NULL COMMENT '内容',
  target_user_id bigint DEFAULT 0 COMMENT '回复的目标用户id',
  target_reply_id bigint DEFAULT 0 COMMENT '回复的目标回复id',
  reply_times int NOT NULL DEFAULT 0 COMMENT '评论数',
  liked_times int NOT NULL DEFAULT 0 COMMENT '点赞数',
  hidden bit(1) NOT NULL DEFAULT b'0',
  anonymity bit(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (id),
  KEY idx_question_id (question_id)
);

三个关联字段各司其职:

字段 用途
question_id 无论回答还是评论,都记所属问题
answer_id 回答时=0,评论时=对应回答的 id
target_user_id "张三评论了李四"里的"李四"
target_reply_id 记录当前评论针对的那条评论(可能是回答、也可能是评论)

这样设计带来一个好处:查询"某个问题下所有回答",就是 WHERE question_id = ? AND answer_id = 0;查询"某个回答下所有评论",就是 WHERE answer_id = ?两层都是单层查询,没有递归
#mermaid-svg-MUlNJrgfwCu8kX9D{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MUlNJrgfwCu8kX9D .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MUlNJrgfwCu8kX9D .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MUlNJrgfwCu8kX9D .error-icon{fill:#552222;}#mermaid-svg-MUlNJrgfwCu8kX9D .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MUlNJrgfwCu8kX9D .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MUlNJrgfwCu8kX9D .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MUlNJrgfwCu8kX9D .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MUlNJrgfwCu8kX9D .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MUlNJrgfwCu8kX9D .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MUlNJrgfwCu8kX9D .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MUlNJrgfwCu8kX9D .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MUlNJrgfwCu8kX9D .marker.cross{stroke:#333333;}#mermaid-svg-MUlNJrgfwCu8kX9D svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MUlNJrgfwCu8kX9D p{margin:0;}#mermaid-svg-MUlNJrgfwCu8kX9D .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MUlNJrgfwCu8kX9D .cluster-label text{fill:#333;}#mermaid-svg-MUlNJrgfwCu8kX9D .cluster-label span{color:#333;}#mermaid-svg-MUlNJrgfwCu8kX9D .cluster-label span p{background-color:transparent;}#mermaid-svg-MUlNJrgfwCu8kX9D .label text,#mermaid-svg-MUlNJrgfwCu8kX9D span{fill:#333;color:#333;}#mermaid-svg-MUlNJrgfwCu8kX9D .node rect,#mermaid-svg-MUlNJrgfwCu8kX9D .node circle,#mermaid-svg-MUlNJrgfwCu8kX9D .node ellipse,#mermaid-svg-MUlNJrgfwCu8kX9D .node polygon,#mermaid-svg-MUlNJrgfwCu8kX9D .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MUlNJrgfwCu8kX9D .rough-node .label text,#mermaid-svg-MUlNJrgfwCu8kX9D .node .label text,#mermaid-svg-MUlNJrgfwCu8kX9D .image-shape .label,#mermaid-svg-MUlNJrgfwCu8kX9D .icon-shape .label{text-anchor:middle;}#mermaid-svg-MUlNJrgfwCu8kX9D .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MUlNJrgfwCu8kX9D .rough-node .label,#mermaid-svg-MUlNJrgfwCu8kX9D .node .label,#mermaid-svg-MUlNJrgfwCu8kX9D .image-shape .label,#mermaid-svg-MUlNJrgfwCu8kX9D .icon-shape .label{text-align:center;}#mermaid-svg-MUlNJrgfwCu8kX9D .node.clickable{cursor:pointer;}#mermaid-svg-MUlNJrgfwCu8kX9D .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MUlNJrgfwCu8kX9D .arrowheadPath{fill:#333333;}#mermaid-svg-MUlNJrgfwCu8kX9D .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MUlNJrgfwCu8kX9D .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MUlNJrgfwCu8kX9D .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MUlNJrgfwCu8kX9D .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MUlNJrgfwCu8kX9D .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MUlNJrgfwCu8kX9D .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MUlNJrgfwCu8kX9D .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MUlNJrgfwCu8kX9D .cluster text{fill:#333;}#mermaid-svg-MUlNJrgfwCu8kX9D .cluster span{color:#333;}#mermaid-svg-MUlNJrgfwCu8kX9D div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MUlNJrgfwCu8kX9D .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MUlNJrgfwCu8kX9D rect.text{fill:none;stroke-width:0;}#mermaid-svg-MUlNJrgfwCu8kX9D .icon-shape,#mermaid-svg-MUlNJrgfwCu8kX9D .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MUlNJrgfwCu8kX9D .icon-shape p,#mermaid-svg-MUlNJrgfwCu8kX9D .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MUlNJrgfwCu8kX9D .icon-shape .label rect,#mermaid-svg-MUlNJrgfwCu8kX9D .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MUlNJrgfwCu8kX9D .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MUlNJrgfwCu8kX9D .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MUlNJrgfwCu8kX9D :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ❓ 问题 id=1
💬 回答 id=11

answer_id=0
💬 回答 id=12

answer_id=0
💭 评论 id=101

answer_id=11

target_reply_id=11
💭 评论 id=102

answer_id=11

target_reply_id=101

target_user_id=?
💭 评论 id=103

answer_id=11

target_reply_id=102

target_user_id=?

这张图说的是:评论 103 虽然是回复的评论 102,但它的 answer_id 仍然是 11(顶层回答)。所以查询"回答 11 下的所有评论"时,直接 answer_id=11 就能拿到 101/102/103 三条,不需要递归。至于展示"张三评论了李四"的文案,通过 target_user_id 单独查一下就行。

这是我在整个问答系统里学到的最有产品思维的一点:技术设计不是越通用越好,要贴合业务展示需求。既然产品层面只展示两层,数据模型就不需要为无限嵌套付代价。

二、冗余字段设计:latest_answer_id

问题表里有这样一个字段:

sql 复制代码
latest_answer_id bigint DEFAULT NULL COMMENT '最新的一个回答的id'

按数据库规范化的思路,这个字段完全可以通过 SELECT id FROM interaction_reply WHERE question_id = ? ORDER BY create_time DESC LIMIT 1 查出来,冗余存储是反范式设计。

但分页查询问题列表时,页面需要展示每条问题的"最新回答内容 + 回答人昵称"。假设一页 5 条问题:

  • 不冗余:查问题(1 次)+ 每问题查最新回答(5 次)+ 每回答查用户(5 次)= 11 次查询
  • 冗余:查问题(1 次)+ 用最新回答 id 批量查回答(1 次)+ 批量查用户(1 次)= 3 次查询

分页列表是"读多写少"的场景 ,加一个字段冗余换 8 次查询的减少,这笔账怎么算都值。回答新增时同步更新问题的 latest_answer_id 就是了。

java 复制代码
// 新增回答时同步更新问题
if (form.getAnswerId() == 0) {  // 是新回答,不是评论
    InteractionQuestion q = new InteractionQuestion();
    q.setId(form.getQuestionId());
    q.setLatestAnswerId(reply.getId());
    questionMapper.updateById(q);
}

冗余字段的取舍判断标准,我总结成一句话:读放大 > 写放大时,冗余。列表查询每次都要 join 或子查询的场景,就是典型的读放大。

问题表里 answer_times(回答数量)字段也是同样的道理------不冗余就要 COUNT(*) GROUP BY question_id,分页场景下这个统计更划不来。

三、管理端按课程名搜索:ES 集成

管理端有个需求:根据课程名称模糊搜索问题。听起来很直接,但看问题表结构就发现一个坑------问题表里没有课程名称字段,只有 course_id

如果直接在 MySQL 里做 WHERE course_name LIKE '%xx%',就得先 join 课程表。跨微服务的情况下,课程表都不在同一个库,join 都做不了。

项目里的解法很巧妙:走 Elasticsearch。所有上线课程的数据本身就在 ES 里做了全文索引(这是搜索服务的常规能力),ES 提供了按课程名称模糊搜索的 Feign 接口,能返回 courseId 集合。流程变成:
#mermaid-svg-XBajxUIDMmw49cGh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XBajxUIDMmw49cGh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XBajxUIDMmw49cGh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XBajxUIDMmw49cGh .error-icon{fill:#552222;}#mermaid-svg-XBajxUIDMmw49cGh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XBajxUIDMmw49cGh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XBajxUIDMmw49cGh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XBajxUIDMmw49cGh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XBajxUIDMmw49cGh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XBajxUIDMmw49cGh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XBajxUIDMmw49cGh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XBajxUIDMmw49cGh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XBajxUIDMmw49cGh .marker.cross{stroke:#333333;}#mermaid-svg-XBajxUIDMmw49cGh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XBajxUIDMmw49cGh p{margin:0;}#mermaid-svg-XBajxUIDMmw49cGh .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-XBajxUIDMmw49cGh .cluster-label text{fill:#333;}#mermaid-svg-XBajxUIDMmw49cGh .cluster-label span{color:#333;}#mermaid-svg-XBajxUIDMmw49cGh .cluster-label span p{background-color:transparent;}#mermaid-svg-XBajxUIDMmw49cGh .label text,#mermaid-svg-XBajxUIDMmw49cGh span{fill:#333;color:#333;}#mermaid-svg-XBajxUIDMmw49cGh .node rect,#mermaid-svg-XBajxUIDMmw49cGh .node circle,#mermaid-svg-XBajxUIDMmw49cGh .node ellipse,#mermaid-svg-XBajxUIDMmw49cGh .node polygon,#mermaid-svg-XBajxUIDMmw49cGh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-XBajxUIDMmw49cGh .rough-node .label text,#mermaid-svg-XBajxUIDMmw49cGh .node .label text,#mermaid-svg-XBajxUIDMmw49cGh .image-shape .label,#mermaid-svg-XBajxUIDMmw49cGh .icon-shape .label{text-anchor:middle;}#mermaid-svg-XBajxUIDMmw49cGh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-XBajxUIDMmw49cGh .rough-node .label,#mermaid-svg-XBajxUIDMmw49cGh .node .label,#mermaid-svg-XBajxUIDMmw49cGh .image-shape .label,#mermaid-svg-XBajxUIDMmw49cGh .icon-shape .label{text-align:center;}#mermaid-svg-XBajxUIDMmw49cGh .node.clickable{cursor:pointer;}#mermaid-svg-XBajxUIDMmw49cGh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-XBajxUIDMmw49cGh .arrowheadPath{fill:#333333;}#mermaid-svg-XBajxUIDMmw49cGh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-XBajxUIDMmw49cGh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-XBajxUIDMmw49cGh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XBajxUIDMmw49cGh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-XBajxUIDMmw49cGh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XBajxUIDMmw49cGh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-XBajxUIDMmw49cGh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-XBajxUIDMmw49cGh .cluster text{fill:#333;}#mermaid-svg-XBajxUIDMmw49cGh .cluster span{color:#333;}#mermaid-svg-XBajxUIDMmw49cGh div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-XBajxUIDMmw49cGh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-XBajxUIDMmw49cGh rect.text{fill:none;stroke-width:0;}#mermaid-svg-XBajxUIDMmw49cGh .icon-shape,#mermaid-svg-XBajxUIDMmw49cGh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XBajxUIDMmw49cGh .icon-shape p,#mermaid-svg-XBajxUIDMmw49cGh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-XBajxUIDMmw49cGh .icon-shape .label rect,#mermaid-svg-XBajxUIDMmw49cGh .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XBajxUIDMmw49cGh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-XBajxUIDMmw49cGh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-XBajxUIDMmw49cGh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

🔍 输入课程名关键词
📡 SearchClient

Feign 调用 ES
🎯 返回 courseId 集合
结果为空?
直接返回空分页
MySQL

WHERE course_id IN (...)
📄 分页返回问题列表

这张图说的是:先用 ES 把"课程名"翻译成"courseId 集合",再把这个集合塞进 MySQL 查询的 IN 条件里。ES 负责全文检索,MySQL 负责结构化过滤,各干擅长的事。

代码:

java 复制代码
// 1.处理课程名称,得到课程id
List<Long> courseIds = null;
if (StringUtils.isNotBlank(query.getCourseName())) {
    courseIds = searchClient.queryCoursesIdByName(query.getCourseName());
    if (CollUtils.isEmpty(courseIds)) {
        return PageDTO.empty(0L, 0L);  // ES 都没搜到,直接空
    }
}
// 2.分页查询,把 courseIds 作为 IN 条件
Page<InteractionQuestion> page = lambdaQuery()
    .in(courseIds != null, InteractionQuestion::getCourseId, courseIds)
    .eq(status != null, InteractionQuestion::getStatus, status)
    .gt(begin != null, InteractionQuestion::getCreateTime, begin)
    .lt(end != null, InteractionQuestion::getCreateTime, end)
    .page(query.toMpPageDefaultSortByCreateTimeDesc());

.in(condition, column, collection) 是 MyBatis Plus 条件查询的用法。当 condition 为 true 时才拼这个 WHERE 片段。这样如果用户没输入课程名(courseIds 为 null),SQL 里就不会有 IN 条件------避免了传统写法的一大堆 if (courseIds != null) wrapper.in(...)

这里也体现了一个通用模式:跨微服务的关联查询,不要用 Feign 循环调用,而是先通过 ES/搜索引擎把"关键字"翻译成"ID 集合",再用 IN 一次性过滤主表。

四、多级缓存:Caffeine 本地缓存 + Redis

管理端返回的问题列表里要带三级分类名称(categoryName),比如"后端开发/Java/JVM"。课程本身关联了三个分类 id,但分类名称得单独查。

问题来了:全站的分类数据一共就那么几十条,但每次分页查询都要用。如果每次都走 Feign 调课程服务查分类,网络开销巨大。就算加 Redis 缓存,还是有一次网络往返。

项目里用了 Caffeine 本地缓存做一级缓存。
#mermaid-svg-Dyr2Wor40P8yDqHn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Dyr2Wor40P8yDqHn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Dyr2Wor40P8yDqHn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Dyr2Wor40P8yDqHn .error-icon{fill:#552222;}#mermaid-svg-Dyr2Wor40P8yDqHn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Dyr2Wor40P8yDqHn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Dyr2Wor40P8yDqHn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Dyr2Wor40P8yDqHn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Dyr2Wor40P8yDqHn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Dyr2Wor40P8yDqHn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Dyr2Wor40P8yDqHn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Dyr2Wor40P8yDqHn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Dyr2Wor40P8yDqHn .marker.cross{stroke:#333333;}#mermaid-svg-Dyr2Wor40P8yDqHn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Dyr2Wor40P8yDqHn p{margin:0;}#mermaid-svg-Dyr2Wor40P8yDqHn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Dyr2Wor40P8yDqHn .cluster-label text{fill:#333;}#mermaid-svg-Dyr2Wor40P8yDqHn .cluster-label span{color:#333;}#mermaid-svg-Dyr2Wor40P8yDqHn .cluster-label span p{background-color:transparent;}#mermaid-svg-Dyr2Wor40P8yDqHn .label text,#mermaid-svg-Dyr2Wor40P8yDqHn span{fill:#333;color:#333;}#mermaid-svg-Dyr2Wor40P8yDqHn .node rect,#mermaid-svg-Dyr2Wor40P8yDqHn .node circle,#mermaid-svg-Dyr2Wor40P8yDqHn .node ellipse,#mermaid-svg-Dyr2Wor40P8yDqHn .node polygon,#mermaid-svg-Dyr2Wor40P8yDqHn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Dyr2Wor40P8yDqHn .rough-node .label text,#mermaid-svg-Dyr2Wor40P8yDqHn .node .label text,#mermaid-svg-Dyr2Wor40P8yDqHn .image-shape .label,#mermaid-svg-Dyr2Wor40P8yDqHn .icon-shape .label{text-anchor:middle;}#mermaid-svg-Dyr2Wor40P8yDqHn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Dyr2Wor40P8yDqHn .rough-node .label,#mermaid-svg-Dyr2Wor40P8yDqHn .node .label,#mermaid-svg-Dyr2Wor40P8yDqHn .image-shape .label,#mermaid-svg-Dyr2Wor40P8yDqHn .icon-shape .label{text-align:center;}#mermaid-svg-Dyr2Wor40P8yDqHn .node.clickable{cursor:pointer;}#mermaid-svg-Dyr2Wor40P8yDqHn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Dyr2Wor40P8yDqHn .arrowheadPath{fill:#333333;}#mermaid-svg-Dyr2Wor40P8yDqHn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Dyr2Wor40P8yDqHn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Dyr2Wor40P8yDqHn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Dyr2Wor40P8yDqHn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Dyr2Wor40P8yDqHn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Dyr2Wor40P8yDqHn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Dyr2Wor40P8yDqHn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Dyr2Wor40P8yDqHn .cluster text{fill:#333;}#mermaid-svg-Dyr2Wor40P8yDqHn .cluster span{color:#333;}#mermaid-svg-Dyr2Wor40P8yDqHn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Dyr2Wor40P8yDqHn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Dyr2Wor40P8yDqHn rect.text{fill:none;stroke-width:0;}#mermaid-svg-Dyr2Wor40P8yDqHn .icon-shape,#mermaid-svg-Dyr2Wor40P8yDqHn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Dyr2Wor40P8yDqHn .icon-shape p,#mermaid-svg-Dyr2Wor40P8yDqHn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Dyr2Wor40P8yDqHn .icon-shape .label rect,#mermaid-svg-Dyr2Wor40P8yDqHn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Dyr2Wor40P8yDqHn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Dyr2Wor40P8yDqHn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Dyr2Wor40P8yDqHn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 命中
未命中
命中
未命中
🔍 业务查询分类名称
🥇 Caffeine

JVM 内存
✅ 返回
🥈 Redis

分布式缓存
回写 Caffeine + 返回
🥉 MySQL

数据库
回写 Redis + Caffeine

这张图说的是经典的多级缓存查询链路:先查本地内存(无网络),没有再查 Redis(有一次网络),还没有再查数据库。数据越"热",越靠近应用;数据越"冷",越往下走。

本地缓存适合什么场景?两个特点:数据量小 + 长时间不变。课程分类恰好符合------全站分类不到一百条,可能半年都改不了一次。

本地缓存的取舍:

缓存类型 优点 缺点
本地缓存 (Caffeine) 无网络、纳秒级读取 各实例不共享、容量受限、一致性差
分布式缓存 (Redis) 集群共享、容量大 有网络往返、毫秒级
数据库 一致性最强 磁盘 IO,最慢

对分类这种数据,"各实例不共享 + 一致性差"完全不是问题------每个实例都各自缓存一份就行,最多 30 秒后过期重新拉。用一致性换性能,很划算。

Caffeine 是 Java 8 之后最主流的本地缓存库,Spring 官方的 Cache 抽象也用它做实现。用法:

java 复制代码
@Bean
public Cache<String, Map<Long, CategoryBasicDTO>> categoryCaches() {
    return Caffeine.newBuilder()
        .initialCapacity(1)                          // 初始容量
        .maximumSize(10_000)                         // 最大条目数
        .expireAfterWrite(Duration.ofMinutes(30))    // 写入后 30 分钟过期
        .build();
}

Caffeine 提供三种驱逐策略:

策略 方法 适用场景
基于容量 maximumSize(n) 数据条目可控,按数量限制
基于时间 expireAfterWrite/AfterAccess 数据有一定时效性
基于引用 weakKeys()/weakValues() 依赖 GC 回收,性能较差,一般不用

一个面试细节:Caffeine 的过期不是"到期立刻清理",而是在下一次读写或者空闲任务触发时才驱逐。因为对每一个 key 起一个定时器代价太大。如果需要"到期立刻通知"的语义,用 Redis 的过期 key 通知或者时间轮。

分类缓存工具类的用法:

java 复制代码
public class CategoryCache {
    private final Cache<String, Map<Long, CategoryBasicDTO>> cache;
    private final CategoryClient categoryClient;
    
    public String getCategoryNames(List<Long> categoryIds) {
        Map<Long, CategoryBasicDTO> all = cache.get("categories", 
            k -> {
                // 缓存未命中时才走的加载逻辑
                List<CategoryBasicDTO> list = categoryClient.listAll();
                return list.stream().collect(
                    Collectors.toMap(CategoryBasicDTO::getId, c -> c));
            });
        return categoryIds.stream()
            .map(id -> all.get(id).getName())
            .collect(Collectors.joining("/"));
    }
}

cache.get(key, loader) 是 Caffeine 的"读取模式",未命中时自动执行 loader 加载,加载过程用 CAS 保证只有一个线程真正去加载。这比自己写 if (null) { load; put; } 优雅得多。

业务代码里用起来:

java 复制代码
// 在管理端分页查询里,直接注入 CategoryCache
vo.setCategoryName(categoryCache.getCategoryNames(cInfo.getCategoryIds()));

其他服务想复用这个能力,只需要引入 tj-api 依赖就行------公共缓存工具类下沉到 common 模块是微服务架构里做性能优化的常见做法。

五、VO 组装:批量查询 + Map 映射

分页查询列表时最难处理的是"数据分散在不同服务"的问题。以用户端问题列表为例,VO 需要的字段来源至少有四处:

  • 问题基础信息 → 本库 interaction_question
  • 提问者昵称头像 → Feign 调 user-service
  • 最近回答内容 → 本库 interaction_reply(用 latest_answer_id)
  • 回答者昵称头像 → Feign 调 user-service

如果一条条问题分别去查关联数据,就是典型的 N+1。5 条问题就是 5 次回答查询 + 10 次用户查询(提问者 + 回答者各一次)。

正确的做法是"先收集所有 id → 批量查询 → 转 Map → 循环里用 Map 拿"。

java 复制代码
// 1.分页查询问题
Page<InteractionQuestion> page = lambdaQuery()
    .select(InteractionQuestion.class, 
        info -> !info.getProperty().equals("description"))  // 排除大字段
    .eq(query.getOnlyMine(), InteractionQuestion::getUserId, UserContext.getUser())
    .eq(courseId != null, InteractionQuestion::getCourseId, courseId)
    .eq(sectionId != null, InteractionQuestion::getSectionId, sectionId)
    .eq(InteractionQuestion::getHidden, false)
    .page(query.toMpPageDefaultSortByCreateTimeDesc());

// 2.收集所有需要查的 id
Set<Long> userIds = new HashSet<>();
Set<Long> answerIds = new HashSet<>();
for (InteractionQuestion q : page.getRecords()) {
    if (!q.getAnonymity()) {
        userIds.add(q.getUserId());
    }
    answerIds.add(q.getLatestAnswerId());
}

// 3.批量查回答(一次 selectBatchIds)
Map<Long, InteractionReply> replyMap = new HashMap<>();
if (CollUtils.isNotEmpty(answerIds)) {
    replyMapper.selectBatchIds(answerIds)
        .forEach(r -> {
            replyMap.put(r.getId(), r);
            if (!r.getAnonymity()) {
                userIds.add(r.getUserId());
            }
        });
}

// 4.批量查用户(一次 Feign 调用)
Map<Long, UserDTO> userMap = new HashMap<>();
if (CollUtils.isNotEmpty(userIds)) {
    userMap = userClient.queryUserByIds(userIds).stream()
        .collect(Collectors.toMap(UserDTO::getId, u -> u));
}

// 5.循环组装 VO,直接从 Map 取
for (InteractionQuestion r : page.getRecords()) {
    QuestionVO vo = BeanUtils.copyBean(r, QuestionVO.class);
    if (!r.getAnonymity()) {
        UserDTO u = userMap.get(r.getUserId());
        vo.setUserName(u.getName());
        vo.setUserIcon(u.getIcon());
    }
    InteractionReply reply = replyMap.get(r.getLatestAnswerId());
    if (reply != null) {
        vo.setLatestReplyContent(reply.getContent());
    }
}

5 条问题,最终只发了 3 次数据库/服务调用:分页查问题、批查回答、批查用户。如果是 N+1 就是 11 次。数据量越大,差距越明显。

这里还有一个技巧值得说:.select(InteractionQuestion.class, info -> !info.getProperty().equals("description")) 是 MyBatis Plus 的字段过滤器 。问题的 description 是 2048 字节的富文本字段,分页列表根本不展示,SQL 里直接不查这一列。数据量大时,减少不必要的字段传输能显著降低 IO 和内存开销。

六、匿名处理:一致的策略贯穿三层

匿名是问答系统的产品需求:用户提问时可以选择匿名,其他用户看不到提问人信息。这个看似简单的需求,代码里要在三个层面保持一致:

查询层:匿名的记录不去查用户信息

java 复制代码
if (!q.getAnonymity()) {
    userIds.add(q.getUserId());  // 匿名不加入待查集合
}

返回层:VO 里不返回 userId

java 复制代码
vo.setUserId(null);  // 先无条件清空
if (!r.getAnonymity()) {
    vo.setUserId(userDTO.getId());  // 非匿名才填
}

统计层:管理端和统计口径不受匿名影响

java 复制代码
// 管理端查询:忽略匿名,全部返回用户信息
// 用户端查询:匿名不返回

"先设 null 再条件覆盖"这个模式比"if/else 两种分支设置"更清爽,特别是当 VO 字段多、匿名字段有好几个的时候。

七、用户端与管理端:为什么不能复用接口

看起来两者都是"分页查问题列表",但项目里定义为两个独立接口。原因我总结成三点:

差异点 用户端 管理端
隐藏字段过滤 WHERE hidden = false 不过滤,且返回 hidden 字段
匿名处理 匿名不返回用户信息 忽略匿名,全部返回
排序/统计 简单 支持按回答数排序、按课程名搜索

微服务里"看起来一样的接口不要硬合并" 。用户端接口一旦合并了管理端逻辑,就得多传一个 isAdmin 参数、多一段分支处理。看似省了一个接口,实际上:

  • 权限边界模糊:万一 isAdmin 被恶意传入,直接暴露所有匿名用户信息
  • 缓存策略不同:用户端可能要走 CDN 缓存,管理端不能
  • 迭代耦合:任何一端要改都要考虑另一端

我现在的判断标准是:两个接口只要有一个字段级别的差异,就分开发。接口多几个不丢人,安全边界糊了才要命。

八、整体感受

问答系统的技术难度不算大,但里面很多"看起来简单,做起来讲究"的细节让我印象很深。

最颠覆认知的是无限嵌套评论做成两层简化 。以前我以为这种社交功能的表结构必然是 parent_id 递归自关联,查询要么靠递归 CTE 要么靠应用层拼树。这次发现,产品需求只要展示两层,表结构就可以通过 answer_id 一步扁平化------查询变成两次 WHERE,性能天差地别。"够用就好"这句话在架构设计里不是妥协,是能力

另一个感受是"批量查询 + Map 映射"这个模式,我在优惠券、课表、问答三处都用到了。之前学 MyBatis 只关注 SQL 怎么写,进项目才发现服务层的性能优化主要靠这个套路

Caffeine 是这次的新收获。之前对缓存的印象就是 Redis,这次意识到对"数据量小 + 更新不频繁 + 允许短暂不一致"的数据,本地缓存比 Redis 快一到两个数量级。多级缓存不是"加一层更快",而是"每一层解决不同性质的问题"。

面试时这块可以聊的点很多:评论系统的表设计思路(无限递归 vs 两级扁平)、冗余字段的取舍、ES + MySQL 联动的搜索方案、多级缓存架构、Caffeine 三种驱逐策略、N+1 问题的批量化解、匿名场景的一致性处理。基本上每个点都能撑住面试官一轮追问。

下一篇想看这个项目的推荐系统相关部分,或者回头看有没有可能把已经写过的这些内容做一次串联整理,形成一份"在线教育平台后端"的完整复盘。

相关推荐
倔强的石头_1 小时前
SQL Server数据库迁移,为什么 KES V9R4C019 能把改代码变成改连接
数据库
yujunl1 小时前
找不到指定的SDK(“Microsoft.NET.Sdk.Web“)
开发语言
黑色的白兔No11 小时前
deepin 25安装mysql
数据库·mysql·debian
郝学胜-神的一滴1 小时前
C++11 工程级应用 12:编译期类型魔法,干掉重复与臃肿的代码
开发语言·数据结构·c++·软件工程·visual studio
CoderYanger1 小时前
Java EE 进阶:2.3 JavaScript 综合案例
java·前端·javascript·css·职场和发展·java-ee·html
starzy19901 小时前
Flink TimeWindow 详解及代码实现:从三种时间语义到滚动与滑动窗口
java·服务器·flink
重生之小比特1 小时前
【Java SE】类和对象(一)
java·ide·intellij-idea
m0_734571761 小时前
深入理解C++ 析构函数<四>虚析构函数
开发语言·c++
cc5725026531 小时前
2026 秋招采购数据分析校招 JD、面试真题与项目准备|2027 届求职复盘
数据库