ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

评论模块设计与实现:从自关联表到游标分页与缓存优化

评论模块设计与实现:从自关联表到游标分页与缓存优化 1. 章节定位评论模块到底在解什么题说实话评论模块在几乎所有业务系统里都是“看着简单写起来全是坑”的典型。这一章放到整个后端项目实战的第十二章其实是有讲究的。前面几章我们已经把用户体系、文章内容、异步任务队列搭完了到了评论这里核心不是“怎么存一条评论”而是要解决三个真正别扭的问题一是评论和用户、文章的关系怎么建模才能既支持楼中楼回复又不会让查询写出五层 JOIN二是怎么写接口才能让前端拿到数据结构足够稳定不至于今天改一版明天又改一版三是面对评论这种高写入、高频访问、还有内容安全压力的场景怎么在可用性和性能之间取一个平衡点。很多同学在简历上写“开发过评论模块”但一问细节基本都停留在“一张表、一个 save、一个 list”的水平。这不怪谁因为大部分教程示例根本不会把业务约束考虑进去。比如说删评论是物理删还是逻辑删这两种做法对子评论的展示逻辑影响就完全不同再比如说分页参数要传 page 还是 cursor这决定了你的接口能不能扛住用户连翻 50 页。我在这一章里用的方案是经过实际项目反复打磨过的一套组合Django 自关联表存关系逻辑删除保底游标分页扛压再加一层缓存挡热点。下面会把每个环节的设计依据、实现细节和踩坑记录都铺开讲透。再说说这一章适合谁。如果你正在做一个前后端分离项目恰好卡在“评论接口到底怎么设计”这一步那这篇的内容基本能让你直接照抄作业如果你只是想补一补后端设计的功底评论区这种“多对一、自关联、高并发读、内容审核”杂糅的业务场景也是个特别好的练手样本。另外如果你们团队的前端同事整天为评论数据格式跟你扯皮这篇文章也可以作为接口契约的沟通底稿。我不太喜欢讲纯理论所以下面每段内容都能对应到实际代码和请求响应你跟着做一遍就能拿下来跑。2. 评论模块的整体设计与数据库建模2.1 先想明白评论在产品层面到底有哪几种形态设计数据库之前我习惯先穷举产品需求因为表结构一旦落地后面跑数、联调、加功能都绕不开它。评论模块最常见的形态有三种平铺式、楼中楼式、盖楼式。平铺式最简单就是评论列表一拉到底谁也不回复谁适合新闻、视频这种“用户发表态”的场景。楼中楼式以二级结构为主一级评论下挂若干条回复回复不再无限嵌套这种形态在电商淘宝、京东、社交平台微博早期、知乎都很常见因为产品经理终于意识到无限嵌套对用户和后端都是灾难。盖楼式是论坛老传统楼主发帖、后面每一层都能被任意回复衍生出“第 XX 层”这种说法做起来最复杂因为它需要同时维护楼层号、引用关系、被折叠的上下文。我做的这个项目产品经理最后拍板是楼中楼。理由倒也务实第一绝大多数用户回复的是“某条评论”而不是“某个回复”二级结构足够用第二无限嵌套在移动端 UI 上根本展示不开每次都要求后端一次性返回整条链路数据冗余严重第三同样是查一个父评论下的所有子评论二级结构能用一条查询搞定无限嵌套就得递归查。所以在动手建表之前先跟产品确认清楚到底要哪种形态这是我在无数个项目里总结出的第一优先级。2.2 表结构设计一道自关联题确定了楼中楼之后评论表的核心字段其实就清晰了。以 Django 模型举例我最终落地的字段大致是这样class Comment(models.Model): # 评论所属的内容对象这里用 ContentType 可以实现多态关联 content_type models.ForeignKey(ContentType, on_deletemodels.CASCADE) object_id models.PositiveIntegerField() content_object GenericForeignKey(content_type, object_id) user models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE) # 自关联关键字段父评论 parent models.ForeignKey( self, nullTrue, blankTrue, on_deletemodels.CASCADE, related_namechildren ) # 如果是回复某条二级评论这里冗余存根评论ID root models.ForeignKey( self, nullTrue, blankTrue, on_deletemodels.CASCADE, related_namedescendants ) content models.TextField(max_length1000) status models.SmallIntegerField( choices((0, 待审核), (1, 已发布), (2, 已拒绝), (3, 已删除)), default0 ) like_count models.PositiveIntegerField(default0) reply_count models.PositiveIntegerField(default0) created_at models.DateTimeField(auto_now_addTrue, db_indexTrue) class Meta: db_table comment indexes [ models.Index(fields[content_type, object_id, status]), models.Index(fields[root, created_at]), ]有几个字段要单独解释。parent 字段解决的是“这条评论回复了谁”当它是根评论时值为 null。root 字段解决的是“这条回复属于哪条根评论”它和 parent 的区别在于如果用户回复的是一条二级评论那么 parent 指向那条二级评论而 root 指向楼中楼的第一层。为什么要冗余 root因为查询“某条根评论下的所有回复”时如果只靠 parent你需要先查出所有根评论再对每个根评论查询它的子评论这是一个典型的 N1而后端高频查询恰恰是“楼中楼整体加载”所以冗余一个 root 字段让二级评论直接挂在根评论节点下查询就能写成一次 WHERE rootxxx。还有一个容易踩坑的点是外键删除策略。评论数据是用户产生的除非涉及法律合规否则一般不做物理删除。所以这里我设计为逻辑删除也就是通过 status 字段把一条评论置为 3。这样做的直接好处是删掉一条根评论时它的所有二级回复可以继续保留在数据库里前端展示时统一过滤掉已删除的根节点子回复仍然能展示出来只是父级会显示“该评论已删除”。如果你用物理删除并且外键设了 CASCADE一旦删了根评论下面几十条回复全部没了这在运营侧是不可接受的。所以这种设计不是炫技纯粹是业务要求。2.3 状态机与审核流的取舍status 字段设了四个状态0 待审核、1 已发布、2 已拒绝、3 已删除。有人可能觉得一个小项目没必要做这么复杂的状态机。但评论模块一旦上线内容审核几乎是必须面对的。审核流有两种落法。一种是严格的预审核所有评论先进待审池运营人工或自动机审核通过后才公开展示好处是绝对安全坏处是用户发完评论看不到效果互动率直线下降。另一种是事后审核评论立即展示系统通过敏感词过滤、风控策略识别高风险内容并异步下架。实际项目中绝大多数平台的默认策略是“先发后审”加上异步机器审核。我在这个模块里做的是折中系统内置一个敏感词服务如果内容命中高危词直接置为待审核否则立即置为已发布运营后台还有一个批量审核列表方便人工二次处理。这个设计直接影响读接口。查询列表时你不能把待审核和已拒绝的评论暴露给其他用户但用户可以查到自己发的评论是什么状态这也就是为什么查询接口里需要额外带一个 user 维度。这个逻辑听着简单但很容易写漏最常见的问题就是开发时用超级用户测试所有状态都能看到一换普通用户就出 bug。3. 评论接口设计与前后端契约3.1 接口清单与字段设计原则我习惯先把接口清单定下来再逐个实现因为前后端分离项目里接口契约早一天定前端就能早一天开工。评论模块这一章我最终定了 5 个接口可以说已经覆盖了绝大多数业务场景发布根评论POST /api/v1/comments回复评论POST /api/v1/comments带 parent_id根评论分页列表GET /api/v1/comments?article_idxxxcursorxxx某根评论下的二级回复列表GET /api/v1/comments/{root_id}/replies?cursorxxx删除评论DELETE /api/v1/comments/{comment_id}接口字段设计上有一个原则返回结构只表达树形关系不承担业务拼装。很多后端同学容易犯的错是在接口里直接告诉前端“这条评论是第几层”“这条评论是不是该显示删除按钮”这些判断应该由前端根据父子关系和当前登录用户自己算。后端需要表达清楚的无非就是 parent_id、root_id、user_id、created_at、content、status 这几个字段。后端一旦掺和进展示逻辑接口就会变得特别脆产品改一个文案你都要跟着改。有一个字段我权衡了很久最终还是加上了当前登录用户是否点赞过该评论。原因很简单前端如果需要在评论列表里展示“点赞状态”靠点赞 ID 列表去匹配会很别扭匹配逻辑在后端做反而便宜。所以列表接口会接收一个可选的viewer_id参数返回时带上is_liked字段。这个做法在点赞量大的时候会引出一个批量判断的性能问题后面会在性能优化那一章专门讲。3.2 发布评论的完整流程与校验链发布评论这个接口逻辑看起来只有一个 save但实际上要走的校验链相当长。完整流程我拆成下面几步用户鉴权从 JWT 或者 Session 中解析当前用户拿不到就返回 401。内容校验长度非空、不超 1000 字敏感词过滤命中高危词则 status 置为 0。防重复提交前端会做按钮置灰但后端不能依赖前端需要从请求里取一个client_request_id在 Redis 里以 SETNX 方式做幂等重复请求直接返回上一次结果。归属校验如果是回复评论需要校验 parent_id 存在且属于同一篇文章不然会出现“跨文章回复”的脏数据。这个错误我在真实项目里见过因为前端传参只传了 parent_id没有传文章 ID结果用户从文章 A 的评论区回复了文章 B 里的评论数据直接错乱。写库与计数更新新评论入库如果带有 root则对 root 的reply_count做原子自增如果是根评论则对文章评论数自增。异步事件发送通知给被回复者、触发内容审核任务这里和前面章节搭好的 celery 才能真正联动起来。防重复提交这块很多团队会把方案定成“后端用时间戳去重”或者“前端按钮禁用”但这两个都有漏洞。前端禁用只能防手抖不能防网络重试时间戳去重粒度太粗用户在 1 秒内的两次真实操作会被误伤。最终方案是每次进入评论框时由前端生成一个 UUID提交时带上后端 Redis 记录这个 UUID 对应的评论 ID过期时间设 5 分钟。同一个 UUID 重复提交时第二次直接返回第一次的结果状态码也保持一致前端完全感知不到多了一次请求。实测下来这个方案既防了重复提交又让前端拿到的数据是幂等的连提示都不用改。3.3 列表查询与树形结构组装根评论列表返回的数据结构我定为两层第一层是根评论数组第二层是每个根评论下最多返回 3 条“最新回复”用于在评论列表中展示一个“展开查看全部回复”的入口。这么做有两个原因一是移动端列表加载性能不可能一次性把所有二级评论都倒出来二是树形结构如果全量返回JSON 体积会变得很大前端首屏渲染计算也比较麻烦。后端组装这个结构时最容易出现 N1 查询问题。举个例子第一页根评论有 20 条如果你在循环里逐条查每个根评论的回复就是 1 条主查询加 20 条子查询一共 21 次数据库往返。怎么优化两个办法结合着用第一次查询只查根评论游标分页拿到一页根评论 ID 列表。用一次WHERE root_id IN (...)查出这批根评论下的“最新回复”排除掉明显不相关的时间范围然后按 root_id 在内存里分组。这里还要小心排序干扰。MySQL 里如果先对回复按时间排序再取每个 root 的前 3 条直接写 GROUP BY 是取不准确的正确做法是用窗口函数SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY root_id ORDER BY created_at DESC) AS rn FROM comment WHERE root_id IN (...) AND status 1 ) t WHERE rn 3;这条 SQL 在数据量上来之后性能依然不错前提是(root_id, created_at)联合索引一定要建好。这里其实有点反常识第一条索引是(content_type, object_id, status)第二条是(root, created_at)两条索引各管一个查询场景缺一个就会让另一个场景走全表扫描。游标分页是评论列表接口里另一个重点。很多项目用的是page1size20这种传统分页但评论列表高频翻页时传统分页有两个致命问题一是用户翻到第 50 页数据库要扫过前面 1000 条才能定位延迟飙升二是如果这期间有新评论插入第一页数据会整体往后挪用户会看到重复的评论。游标分页的思路是每次请求传cursor参数值为当前页最后一条评论的created_at时间戳后端查询时加一个created_at cursor的条件。这样即使新评论不断进来用户正在翻的列表也不会乱。用游标分页实现“加载更多”特别自然但要注意一个问题如果列表排序还涉及“热度”比如按点赞数排序那么游标就不能只基于 created_at 了需要为热度排序单独设计游标。我这版为了简洁评论列表统一按时间倒序按热度排序的功能后续再通过单独的 Redis ZSET 来做。4. 评论列表的分页、缓存与排序策略选择4.1 为什么最终选了游标分页而不是页码分页开发时我特地把两种分页都写了一遍对比了真实数据量下的表现。传统页码分页的查询优化方式一般是SELECT * FROM comment WHERE content_typexxx AND object_idxxx AND status1 ORDER BY created_at DESC LIMIT 20 OFFSET 980;这条 SQL 的时间消耗主要落在 OFFSET 上。MySQL 的 LIMIT 实现是“计数并丢弃”也就是说 OFFSET 980 意味着数据库要把前 980 条都读出来扔到一边才能返回 981 到 1000 条。一旦文章评论区数据量跑到几万条用户点进最后一页接口延迟直接上 500ms 以上。而且这里还有缓存的问题页码分页的缓存 key 通常是page1这种只要有一条新评论插入page1 的缓存就得失效但 page2、page3 的缓存还在于是用户看到第 1 页和之前不一样后几页又是旧的体验非常分裂。游标分页的查询写法虽然多了一个条件但实际上能稳定命中索引范围扫描SELECT * FROM comment WHERE content_typexxx AND object_idxxx AND status1 AND created_at %s ORDER BY created_at DESC LIMIT 20;每次查询只扫游标之后的 20 条时间不随页码加深而增长这是它最大的优势。另外游标分页天然适合移动端“加载更多”的交互不需要总页数不需要跳页每次拿到next_cursor塞给下一次请求就行。它的缺点也很明显不能快速跳转到第 50 页但评论区这个场景用户几乎不会这么做。产品侧如果非要“跳页”大概率是运营在后台管理场景需要那就在后台单独提供一套页码分页接口不要把两个逻辑混在一起。4.2 缓存设计缓存根评论列表和二级回复计数评论模块的读多写少特征非常明显一篇文章被阅读的次数是评论次数的几十倍上百倍所以列表接口必须走缓存。但我没有选择最偷懒的“缓存整个接口响应 JSON”因为这个方案一旦评论量上来缓存失效和更新会让你痛不欲生。我采取的是二级缓存拆解第一层整篇文章的根评论列表缓存 key 设计为comment:root_list:{content_type}:{object_id}:{status}缓存内容是第一页 20 条根评论的核心字段 JSON。第二层每个根评论下的二级回复列表缓存 key 为comment:reply_list:{root_id}:{cursor}缓存容量小失效策略按 LRU。第三层只做计数缓存比如comment:reply_count:{root_id}和comment:total_count:{article_id}因为这个值在列表展示中必须实时准确不适合整页缓存所以干脆单独存更新时用 INCR 或直接重算。缓存更新怎么做到一致性一开始我试过“写库后主动删除缓存”也叫 Cache Aside 模式但会有个非常经典的问题写请求 A 更新数据库后准备删缓存这时候读请求 B 查到旧数据写回了缓存之后 A 删缓存的动作就白删了缓存里又是旧值。解决的办法有几种最实用的还是“延迟双删”即写库后先删一次缓存再隔几百毫秒删第二次。这个方案简单有效但删除操作本身会浪费一些请求所以实际项目中我还叠加了一个兜底方案缓存设置 5 分钟过期即使双删偶尔失效最长 5 分钟也会自愈。对于评论这种允许轻微延迟一致的场景这个策略是性价比较高的。还有一点想提醒大家缓存里尽量不要直接存 ORM 对象。Python 的 pickle 序列化 ORM 对象字段一多就非常臃肿而且升级模型时老缓存会直接反序列化报错。我都是先把查询结果转成字典只保留接口需要的字段再序列化成 JSON 字符串塞到 Redis。这样缓存体积小、排错容易前端拿到的数据也稳定。4.3 排序策略时间排序与热度排序的取舍评论列表的排序在真实产品里经常被产品经理反复改需求。一开始上线用的是纯时间倒序最热的评论可能被压在第五页引发不少用户吐槽。后来产品要求按“热度”排序于是我们又引入了点赞数的权重。实现热度排序我比较推荐 Redis ZSET而不是直接在 MySQL 里ORDER BY like_count DESC。原因有几个第一点赞操作是很高频的写操作直接在 MySQL 中更新 like_count 会频繁触发行锁第二热度排序天然适合用 ZSET 的 score 来承载score 可以直接是点赞数 * 2 回复数 * 3这种加权值第三ZSET 的分页查询非常快ZRANGEBYSCORE 加游标就能实现稳定分页。ZSET 的 key 设计为comment:rank:{article_id}每当一条评论被点赞或新增回复就用ZINCRBY更新对应 score。但要注意ZSET 里的元素是评论 ID最终展示时还要回查 MySQL 拿评论内容于是又引入了“缓存里面存评论 ID回源时批量取评论详情”的套路。顺序上必须先查 ZSET 拿 ID 列表再批量查评论详情最后按 ID 顺序重排。这一步看起来多余但在高并发场景下能显著减少 MySQL 压力值得花代码去实现。对于根评论列表和二级回复列表我做了差异化的排序规则。根评论默认按热度排同时给最新评论留一个“最新” Tab二级回复则永远按时间正序排因为楼中楼天然要求对话上下文有序。这两个规则和前端交互是逐条对齐过的要避免出现“后端自己觉得排序合理结果前端展示顺序很怪”的问题。接口上根评论列表加一个sort_byhot|new参数默认 hot二级回复不带排序参数强制时间正序。5. 从数据库到接口核心实现与联调记录5.1 发布接口的完整代码脉络发布根评论和回复评论走的是同一个接口我在代码里用一个嵌套序列化器区分它们。为了让你能对照参考我把关键代码梳理出来当然这里省略了鉴权装饰器等不相关细节核心逻辑如下class CommentCreateSerializer(serializers.ModelSerializer): parent_id serializers.IntegerField(requiredFalse, allow_nullTrue) client_request_id serializers.UUIDField(requiredTrue) class Meta: model Comment fields [content, parent_id, client_request_id] def validate(self, attrs): content attrs.get(content, ).strip() if not content: raise serializers.ValidationError(评论内容不能为空) parent None root None parent_id attrs.get(parent_id) if parent_id: try: parent Comment.objects.select_for_update().get(idparent_id) except Comment.DoesNotExist: raise serializers.ValidationError(父评论不存在) # 关键跨文章评论校验 if parent.content_type ! self.context[content_type] or \ parent.object_id ! self.context[object_id]: raise serializers.ValidationError(不能跨文章回复评论) root parent.root if parent.root_id else parent attrs[parent] parent attrs[root] root # 敏感词校验由审核异步任务最终确认 attrs[status] 0 if self._hit_sensitive(content) else 1 return attrs这段代码里有两个点值得专门讲讲。一是select_for_update()为什么要锁行因为如果两条回复同时发生时子评论数和父评论状态可能产生并发下的一致性问题。行锁能保证这条父评论在同一时刻只被一个事务处理避免 reply_count 自增错乱。二是“跨文章回复”的校验这个校验必须在 serializer 层面做不能只靠前端传参约束因为只要有人手动调接口绕过前端就能伪造出非法关联。发布成功后的计数更新同样是事务的一部分with transaction.atomic(): comment serializer.save(userrequest.user) if comment.root_id: Comment.objects.filter(idcomment.root_id).update( reply_countF(reply_count) 1 ) else: Article.objects.filter(idcomment.object_id).update( comment_countF(comment_count) 1 )使用F()表达式做自增是一个容易忽略但很重要的细节。如果直接写comment.reply_count 1再 save相当于先把数据从数据库读出来在 Python 里加 1再整体写回去并发下会丢更新。F 表达式则把自增操作下推到数据库执行天然原子不需要额外加锁。5.2 列表接口与数据组装代码根评论列表演示了游标分页加二级回复联查的完整流程。我直接贴核心查询逻辑def fetch_root_comments(content_type, object_id, cursor, limit20): qs Comment.objects.filter( content_typecontent_type, object_idobject_id, parent_idNone, # 只查根评论 statusComment.Status.PUBLISHED ).order_by(-created_at) if cursor: cursor_dt parse_datetime(cursor) qs qs.filter(created_at__ltcursor_dt) roots list(qs[:limit 1]) has_next len(roots) limit roots roots[:limit] # 批量查每个根评论的最新3条回复 root_ids [r.id for r in roots] replies Comment.objects.filter( root_id__inroot_ids, statusComment.Status.PUBLISHED, created_at__gttimezone.now() - timedelta(days30) ).order_by(created_at) # 窗口函数取每个root的前3条或者用Python分组后取值 reply_map defaultdict(list) for reply in replies: if len(reply_map[reply.root_id]) 3: reply_map[reply.root_id].append(reply) items [] for root in roots: root_data comment_serialize(root) root_data[latest_replies] [ comment_serialize(r) for r in reply_map.get(root.id, []) ] items.append(root_data) next_cursor roots[-1].created_at.isoformat() if has_next else None return items, next_cursor有人说用 Python 分组有点土不如直接上窗口函数。道理是没错但对评论列表这种 limit20 的场景窗口函数和 Python 分组的性能差距完全可以忽略反而 Python 分组的代码更容易读、更容易让新接手的人维护。只有回复数量非常庞大的场景我才建议把窗口函数下沉到 SQL。两种方案我都用过最后保留的是 Python 分组因为真实项目里维护性比极致性能更重要。列表接口还有一个容易漏的逻辑当前用户是否点赞。批量查询点赞状态时不能写成循环单独查正确做法是拿评论 ID 列表和当前用户 ID 一次性查出点赞关系集合liked_ids set() if viewer_id: liked_ids set( CommentLike.objects.filter( comment_id__inids, user_idviewer_id ).values_list(comment_id, flatTrue) )然后序列化每个评论时判断comment.id in liked_ids即可。这样不论一页有多少条评论点赞查询都只有一次数据库往返。5.3 跨域与前后端联调中的实际问题前后端分离项目走到联调这一步第一个迎面而来的问题就是跨域。浏览器同源策略会把后端接口挡在门外Django 后端解决这个问题一般用django-cors-headers。配置上有几个关键点容易踩坑INSTALLED_APPS [ ... corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, ... # 尽量放在中间件列表靠前位置 ] CORS_ALLOWED_ORIGINS [ http://localhost:5173, https://your-frontend-domain.com, ]这里有一个我在真实项目中盯了很久的问题CORS_ALLOW_ALL_ORIGINS True只适合本地开发上线前一定要改成显式白名单。另外带着 Cookie 做跨域请求时必须额外配置CORS_ALLOW_CREDENTIALS True前端 fetch 也要加上credentials: include。这一对配置少一个前端请求就会在浏览器控制台报错而且后端日志里根本看不到任何请求记录因为请求在进入 Django 之前就被浏览器拦截了。排查这种问题会很头疼所以我习惯在联调阶段先让前端同事用 Postman 测接口通不通。如果 Postman 完全正常而浏览器报跨域基本就是 CORS 配置问题不用到处找 bug。再有一个联调阶段常见的坑是时区。前端提交评论时如果传了created_at那是一定要拒绝的创建时间必须以后端服务器时间为准不能让客户端传。如果你把 datetime 直接序列化成2025-06-15T10:30:00这种字符串前端展示时默认当成浏览器本地时间就会差 8 个小时。我的做法是后端统一返回带时区的 ISO 字符串也就是2025-06-15T10:30:0008:00前端直接用new Date()解析时区差异就消失了。这个细节不处理好用户会莫名投诉“明明刚发的评论显示时间是 8 小时前”。6. 常见问题与线上排查实录6.1 评论数对不上从计数缓存到 Redis 的排查路径有一次线上反馈一篇文章的评论总数和实际列表对不上总数多列表少。查半天发现是计数缓存出了问题。这个项目的评论计数是先在 MySQL 里维护一个comment_count字段启动时再异步灌入 Redis。但运营在后台“批量删除水军评论”时走的是另一个管理端接口那个接口没有同步更新 Redis 的计数 key导致 Redis 里的数字比真实值大。列表接口读 Redis 总计数列表数据查 MySQL两边口径不一致。最后统一规则所有写操作必须经过同一个服务方法不管是用户删除还是运营批量删除都调同一个delete_comment()这个方法同时负责更新 MySQL 和 Redis。另外Redis 计数 key 全部加业务前缀比如comment:total_count:{article_id}避免和其他模块的计数混在一起。如果你们项目里也出现了“计数对不上”的经典问题我建议按下面顺序排查先判断 MySQL 里的真实值对不对直接SELECT COUNT(*)查原始表。再判断 Redis 缓存值是多少用GET看当前数字。如果 MySQL 正确而 Redis 错误就是缓存更新链路有部分写操作没走缓存更新逻辑。如果 MySQL 本身就不对那基本是删除策略问题比如逻辑删除的数据也被 COUNT 进去了。6.2 缓存穿透、击穿与雪崩的差别化处理评论区列表接口如果没有缓存兜底遇到爆款文章瞬间大量请求打到 MySQL数据库连接池很容易被打满。我排查过几次线上事故发现很多工程师把“缓存穿透”“缓存击穿”“缓存雪崩”三个词混为一谈防护手段也就用错了。缓存穿透指的是查询一个根本不存在的数据缓存里没有数据库里也没有请求每次都穿过缓存打到数据库。比如有人恶意构造不存在的评论 ID 或者文章 ID 去刷接口。防护手段是布隆过滤器或者缓存空值我会在负载高的接口里把不存在的 ID 也缓存一个空结果过期时间短一些比如 60 秒这样恶意刷也能被挡在缓存层。缓存击穿指的是某个热点 Key 在缓存失效的瞬间大量请求同时打到数据库。比如一篇爆款文章热度很高缓存刚好过期一瞬间进来了 5000 个请求如果没有保护数据库压力会瞬间拉满。解决办法是互斥锁Redis SETNX或者逻辑过期用 SETNX 抢一个锁抢到锁的线程去查库并重建缓存其他线程先返回旧缓存或者短暂等待。我常用的一个简单实现是def get_comment_list_with_mutex(key, rebuild_func, expire300): data cache.get(key) if data is not None: return data lock_key f{key}:lock if cache.set(lock_key, 1, nxTrue, ex30): try: data rebuild_func() cache.set(key, data, expire) return data finally: cache.delete(lock_key) else: # 没抢到锁等待后重试一次 time.sleep(0.1) return cache.get(key)这个方案的缺点是抢锁失败那次请求会等待 0.1 秒但对绝大多数场景来说完全够用并且代码逻辑简单容易维护。缓存雪崩指的是大量缓存 Key 同时过期导致所有请求全部落到数据库。解决方案也直观就是给缓存过期时间加随机抖动。比如统一设 300 秒实际设置时在 240 到 360 之间随机让过期时间错开不会出现整点集体失效。6.3 评论内容审核的并发处理评论模块里最容易和产品经理吵起来的就是审核策略。我这边实现的敏感词过滤服务是一套基于 AC 自动机的算法加载词典后在 O(n) 时间内完成匹配速度足够快。但如果每次发评论都在请求链路里同步跑一遍在突发流量下还是会对接口延迟造成压力。我的做法是评论主链路只做最短词表的第一轮检测命中高危词直接进待审池完整深度审核则通过任务队列异步跑审核结果更新评论状态并通过 WebSocket 或者轮询通知前端。这里有一个用户可感知的体验点要提前想清楚如果一条评论进入待审状态作者端要能明确知道“这条评论正在审核中”而不是静默地让评论消失。用户侧展示逻辑是作者自己的评论列表里待审核评论显示为“审核中”其他用户的评论列表里待审核评论被过滤掉。这样既满足合规要求又不至于让用户体验彻底崩掉。要做到这一点列表接口里需要支持一个可选参数区分“看自己发布的待审核评论”和“看所有已发布评论”两套查询条件不要混在一条 SQL 里搞各种 OR那样索引会失效。6.4 从日志与监控反推的联调经验最后分享一个联调阶段排查 bug 的心法。评论模块涉及用户、文章、内容审核、消息通知等多个子服务联调时遇到问题第一反应往往是“猜哪个服务出了问题”这很低效。我的习惯是给评论的每个关键环节打上独立的日志标识比如发布接口生成一个request_id从鉴权、校验、写库、发消息全部串联起来。线上出现问题直接按 request_id 查日志链路很快就能定位是哪个环节断掉了。前期为了图省事我在项目里只打了logger.info(comment created)这种没有上下文的日志结果出了线上问题什么都查不出来只能靠用户复述操作路径去猜。后来改成标准的链路日志包含了用户 ID、文章 ID、评论 ID、请求耗时、状态码。这些信息对排错非常有价值。还有一个特别实用的细节评论计数更新失败不能只记录到日志就完事应该同时上报到监控系统因为这种错误一般不会立刻爆出线上故障但会默默累积成数据不一致等发现时已经很难追回。把这些监控点提前铺好能省掉后期很多大麻烦。7. 评论模块后续可以这样扩展评论模块写到这里核心功能已经完整。但如果你们项目要继续往下走有几个方向是真实业务里很快就会碰到的。第一个是点赞功能前面接口里预留了 is_liked 字段但没有完整实现点赞表。点赞表建议单独建字段包括 user、comment、created_at加唯一约束(user, comment)防重复点赞。点赞计数可以用 Redis 的 INCR/DECR 异步同步到 MySQL或者直接 ZINCRBY 更新评论热度排序。第二个是富文本评论现在内容字段是纯文本如果要支持表情、贴图前端展示和后端存储都要升级注意存储时始终保留纯文本兜底避免 XSS。第三个是评论通知被回复后发站内信或者推送这个和前面章节的异步任务队列配合评论写库后发一条任务即可。第四个是自定义敏感词与黑名单用户管理运营后台需要能动态维护敏感词库和用户等级触发规则后自动折叠评论。每一个方向都是独立的章节量但如果评论表结构一开始就设计成前面那种“冗余 root、逻辑删除、状态机完整”的样子扩展起来会非常顺手。最后说一个我自己的感受。评论区这种业务它不像推荐系统、搜索排序那样有强烈的技术吸引力但它几乎是每个内容型产品的刚需。把评论模块做到稳定、不丢数据、不超时、内容合规需要你在数据库设计、接口契约、缓存一致性、事务边界这些基本功上都没有明显短板。我在多个项目里反复打磨这套方案最大的体会是评论模块的设计水平基本能反映一个后端工程师对数据一致性、性能边界和产品交互的理解深度。上面这整章内容和我在真实项目里的落地实践基本一致每一处参数和方案都经过线上验证。如果你正在开发自己的前后端分离项目照着我这个结构把评论模块做出来大概率能少走很多弯路。当你真正把评论列表的游标分页跑通、把树形结构组装成功、把计数一致性守住时这套思路也可以直接迁移到消息模块、操作日志、动态流等几乎所有“列表加关联”的后端场景里。
返回列表