前面写了几篇优惠券、学习进度的博客,这篇切到互动问答系统。业务本身不复杂------用户提问、回答、评论、点赞。但里面藏了几个我觉得很有意思的技术点:怎么把"无限叠楼"的社交场景简化成两级数据模型;管理端按课程名称搜索问题(问题表根本没这个字段)怎么办;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 问题的批量化解、匿名场景的一致性处理。基本上每个点都能撑住面试官一轮追问。
下一篇想看这个项目的推荐系统相关部分,或者回头看有没有可能把已经写过的这些内容做一次串联整理,形成一份"在线教育平台后端"的完整复盘。