ARTICLE DETAIL

资讯详情

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

高性能评论系统架构设计:从存储模型到缓存与削峰实战

高性能评论系统架构设计:从存储模型到缓存与削峰实战 做社区和UGC产品的同学应该都有体会评论区是最容易“看起来简单上线后却被骂得最惨”的模块。用户发一条评论只需要眨眼的功夫但产品层面却要处理高频写入、树状层级、万人争抢同一条热评、多层分页、点赞计数还要防垃圾和恶意刷楼。单一个“评论盖楼”场景就能把架构水平拉开很大差距。这篇文章想分享一个高性能评论系统——俗称“盖楼”系统——的架构设计案例把背后的存储模型、缓存策略、异步写入链路、高可用降级和压测排障完整过一遍。适合谁看准备自研社区/内容类产品评论模块的后端同学或者正在被老评论系统慢查询、卡顿、雪崩折磨的开发者无论当前用户规模是几十万还是几千万都可以按这个思路裁剪落地。1. 评论系统的核心矛盾与技术选型1.1 先拆需求盖楼评论到底难在哪很多团队一开始都把评论模块做成“一张表 一个列表接口”加上评论和删除功能就上线了。等流量起来之后问题会一个接一个冒出来热帖详情页打不开、楼中楼翻页越来越慢、评论总数对不上、大V一发内容数据库直接报警。这背后的核心矛盾在于“读多写少但写又极集中”以及“数据关系是树形的但展示形态是分页列表”。评论场景的读放大非常夸张。一个帖子同时在线看的人可能有几万人但真正写评论的只有一小部分。常规业务读写比在 10:1 到 20:1遇到热点事件可能变成 100:1。这种场景天然适合“缓存扛读、数据库扛写”的架构。但问题在于评论不像点赞那样只是更新一个计数它还要保存内容、维护父子关系、支持翻页、支持展开楼中楼这就把“读放大”从简单计数放大到了“列表聚合 树关系 多级分页”。另一个容易忽略的点是热点高度集中。评论数据不像订单数据那样分散到海量用户而是集中到少数头部帖子。一个千万级评论量的系统里可能 80% 的访问都打在同一条爆款内容上。这种“热点 Key”问题会在架构设计初期就被放大如果不做缓存数据库会挂如果只做简单缓存热点 Key 过期的一瞬间还是会发生缓存击穿。盖楼树形结构本身也是个麻烦。用户回复“楼中楼”回复的回复还能继续嵌套。如果真按无限层级树去设计查询复杂度几乎不可控因为要递归捞出某个根节点下的所有子孙节点。产品上常用的妥协方案是两级结构一级评论列表 每个一级评论下的“楼中楼”列表。用户看到的是“展开全部 xx 条回复”多半只需要展示该一级评论下的第二层回复而不是真正的无限嵌套树。这个取舍对数据模型和缓存设计影响非常大。1.2 技术栈分配什么数据放 MySQL什么放 Redis先给结论主存储用 MySQL热点数据用 Redis写链路异步化走消息队列搜索和后台管理可以用 Elasticsearch但这不是评论核心链路里的必需品。评论正文、用户 ID、父评论 ID、状态这类数据需要强一致性和事务能力所以放在 MySQL 的 InnoDB 表里最稳妥。有人会问能不能用 MongoDB 存整棵评论树确实有些团队这么干把评论树做成嵌套文档查询很直观但会遇到两个问题一是高并发更新某个父节点的回复数时文档锁竞争非常激烈二是分页和排序在文档型数据库里做起来不如关系型顺手。所以常规方案仍然是“MySQL 负责关系和状态Redis 负责高并发读和计数”。Redis 在评论系统里承担的事非常多热门帖子的评论 ID 列表缓存、评论详情缓存、回复计数、点赞关系、用户维度的限流计数器、楼层号分配全都压在 Redis 上。它速度快但别盲目把所有数据都塞进去要控制 key 的粒度和数量。评论列表缓存可以是 ZSET 或 List但只存 ID 列表不存完整 DTO评论详情才用独立的 key 缓存完整内容。消息队列推荐用 Kafka 或 RocketMQ。评论发布后真正需要同步等待的只有“写入主库成功”其他动作——内容审核、缓存刷新、通知推送、搜索索引更新、数据统计——都应该丢到 MQ 里异步执行。这样做还有一个额外好处当某个大 V 发帖引发评论风暴时MQ 可以把瞬时几十万的写入洪峰削平让数据库按照自己能够承受的速度慢慢消费。下面用一个表格总结各组件在评论系统里的定位方便大家做选型时对照组件职责选型原因不要把什么放进去MySQL评论主数据、状态、关系事务能力、关系查询成熟高并发实时计数、热帖完整列表Redis热点列表、详情缓存、计数、限流低延迟、高吞吐评论全量数据、超长列表Kafka/RocketMQ异步写链路、削峰解耦上下游、削峰填谷强一致的账务逻辑Elasticsearch评论搜索、后台检索分词与复杂查询线上核心读写链路技术选型一定要和团队能力匹配。小团队没有专职 DBA就不要一上来就搞自研分布式数据库先把 MySQL Redis 这套成熟组合用好已经能支撑中等规模的评论系统。2. 数据模型与索引设计让盖楼查询不失控2.1 评论主表与楼中楼表怎么建评论表设计是整个系统的基础。我在多个项目里落地过一套比较通用的结构核心思路是一级评论和楼中楼回复共用一张表用 root_id 和 parent_id 区分层级。下面直接给出建表 SQL。CREATE TABLE comment ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL DEFAULT post, target_id BIGINT UNSIGNED NOT NULL COMMENT 帖子/视频/文章ID, root_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 所属一级评论ID一级评论为0, parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 直接父评论ID一级评论为0, user_id BIGINT UNSIGNED NOT NULL COMMENT 评论人, content TEXT NOT NULL COMMENT 评论内容, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审核 1正常 2删除, like_count INT UNSIGNED NOT NULL DEFAULT 0, reply_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 楼中楼回复数, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_target_status_id (target_id, status, id), KEY idx_root_status_id (root_id, status, id), KEY idx_parent_status_id (parent_id, status, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里最重要的两个字段是 root_id 和 parent_id很多人会疑惑为什么两个都保留。只保留 parent_id 能表达完整树但要从一个一级评论出发“展开全部回复”时必须递归遍历所有子孙节点数据库查询会非常痛苦。保留 root_id 后要展示某个一级评论下的全部楼中楼回复只需要WHERE root_id ? AND status 1 ORDER BY id一次索引扫描就能搞定。parent_id 则用来表达“我回复的是哪一条评论”方便前端做引用提示和上下文定位。content 用 TEXT 类型没问题但有一个隐藏的坑列表查询时千万不要习惯性SELECT *。评论内容可能很长带上它会让 InnoDB 回表读取大字段磁盘 IO 和网络带宽都会被打满。正规做法是列表页先只查id, user_id, like_count, reply_count, created_at, parent_id这些轻量字段然后根据 ID 集合批量查 content或者把 content 放到单独的表/单独缓存里。id 的生成策略建议用雪花 ID 而不是 MySQL 自增。原因有两个一是自增 ID 容易被外部爬虫通过增量规律抓取评论二是分布式环境下多实例写入时需要避免主键冲突和 AUTO_INCREMENT 锁竞争。雪花 ID 在业务上也可以直接用ORDER BY id来实现时间排序因为它的高位包含时间戳单调递增。2.2 排序、分页与总数避开深分页陷阱评论列表的分页是另一个重灾区。最常见的做法是LIMIT 10000, 20但数据库执行时会先扫描前 10020 行再丢弃前 10000 行页数越深越慢。一个百万评论的热帖翻到后面可能一次查询就要几百毫秒甚至一秒以上。更合适的方案是游标分页keyset pagination。按时间倒序的话查询条件可以写成SELECT id, user_id, like_count, reply_count, created_at FROM comment WHERE target_id ? AND status 1 AND id ?cursor_id ORDER BY id DESC LIMIT 20;这样每页查询都走idx_target_status_id索引且扫描的行数恒等于 20 条不会随着页数加深而恶化。前端“加载更多”时把上一页最后一个评论的 ID 传回来即可。如果要按热度排序就把排序列从 id 换成 heat_score游标变成(heat_score, id) (last_score, last_id)同样能走索引。评论总数的问题也要单独看待。直接SELECT COUNT(*) FROM comment WHERE target_id ?在超大数据集上是灾难。生产环境的一般做法是维护计数表或者把计数放在 Redis 里异步更新。列表页展示的“共 xx 条评论”允许短暂不准比如延迟几秒用户不会感知到。真正要求精确的是用户自己刚刚发的评论这个后面写入链路里再说。还有一个容易被忽略的细节刚上线时数据量不大深分页问题不明显数据量到百万级之后翻到第 200 页的慢查询才会浮出水面。所以建表时一定要提前把游标分页的索引设计进去等出问题再改就会牵扯到前端联调和缓存兼容成本高很多。3. 缓存设计扛住热点评论的“读放大”3.1 两级缓存结构本地缓存 Redis热点帖子的评论列表会被反复读取如果每个请求都穿透到 Redis单靠 Redis 也能扛住大部分场景但 Redis 的连接数和网卡会成为瓶颈。更稳的做法是引入两级缓存应用本地内存作为 L1Redis 作为 L2。本地缓存我常用 Caffeine 或 Guava Cache。只用来缓存当前集群最热的一小批帖子的评论列表TTL 控制在 30 到 60 秒。这样同一台机器上的请求命中本地缓存后根本不会触达 Redis。我之前在实际项目里压过一组数据纯 Redis 支撑 2 万 QPS 时 CPU 已经到 80%加了一层本地缓存后同样流量下 Redis CPU 降到 20% 左右效果非常明显。本地缓存的问题是集群内多个实例之间数据可能不一致。评论场景对一致性要求没那么高用户刷新页面后看到几分钟前的状态完全可接受所以不需要做复杂的推播失效用短 TTL 自然过期就够。真正的底线是不能因为本地缓存造成数据错乱比如用户发完评论后看不到自己的内容这种情况必须特殊处理。L2 缓存的形态要区分列表和详情。列表缓存只存评论 ID 列表用 Redis 的 List 或 ZSET 保存评论详情用独立的 String 类型 key 缓存 JSON 序列化后的内容。读取时先拿 ID 列表再批量从详情缓存中取数据。这样做的好处是当某条评论被删除或进入审核状态时只需要更新那一个详情的 key不需要让整个列表缓存失效。3.2 缓存更新策略Cache Aside、双删还是异步刷新评论列表缓存最常见的问题是更新策略错误。经典 Cache Aside 模式要求写操作先更新数据库再删除缓存但在高并发下删除缓存到下次回填之间会有一个空窗期大量请求同时回源数据库很容易把数据库打挂。我推荐的方案是“异步刷新 互斥回源”。具体过程是评论发布后通过 MQ 通知缓存服务将对应 target 的列表缓存标记为“待刷新”不主动删除当读请求发现缓存里的版本号已经落后触发一次回源重建重建时用分布式锁保证只有一个请求去查数据库其他请求短暂等待或直接返回旧数据。还可以通过订阅 MySQL binlog 来感知数据变化比如使用 Canal。这个方案好处是对业务代码侵入小删除、审核状态变化都能自动触发缓存刷新。代价是需要多维护一套 binlog 消费组件建议数据量和团队规模到位后再上。评论详情缓存的 TTL 不要设成固定值。固定 TTL 会导致同一秒内大量 key 同时过期引发缓存雪崩。给每个 key 的过期时间加一个随机量比如 30 到 60 分钟之间随机能有效打散过期时间点。3.3 热点 Key、大 Key 和缓存穿透怎么挡热点帖子的评论可能高达几十万条如果直接把全部评论 ID 都塞进一个 Redis ZSET会形成一个超大的 key。大 key 的问题在于单个命令操作整个 key 时耗时很长做持久化或扩容时也会卡顿一旦过期删除Redis 会短暂阻塞。我在实际排障时就见过一个 50 万成员的 ZSET因为某段逻辑执行ZREVRANGE 0 -1取出全部数据直接把 Redis 阻塞了一秒多。解决方法是“分片缓存 限制缓存深度”。按页拆分列表缓存comment:list:{target_id}:page:{page}每页只存 20 到 50 个评论 ID。热点帖子只缓存最近的前 N 条或最热的前 N 条超出部分直接查库。避免对单个 key 做大范围操作读取永远只取一页。缓存穿透指的是恶意请求大量访问不存在的评论 ID 或帖子 ID导致请求每次都穿透到数据库。常见做法是在业务入口加一个布隆过滤器过滤掉不存在的 ID或者对空结果也做短暂缓存比如缓存一个空数组 60 秒。前者更省空间后者实现更简单评论系统里常用后者。缓存击穿则要针对真正的大热点处理。当某个热点帖子的缓存刚好过期同时几万个请求涌进来单靠加锁回源还不够因为数据库可能同时收到多台机器各自的回源请求。正确姿势是 singleflight单飞同一时刻对同一个 key 只允许一个请求回源数据库其他请求复用这个请求的结果。很多本地缓存组件自带这个能力比如 Caffeine 的CacheLoader配合get(key, loader)可以在 JVM 内做合并跨进程则需要用 Redis 分布式锁。4. 写入链路与削峰评论风暴来临时怎么处理4.1 从提交到可见同步与异步的边界评论发布链路是设计中最容易“翻车”的地方。很多系统第一版采用全同步流程客户端请求进来服务端先做敏感词校验再写 MySQL再更新 Redis再发通知全部成功后才返回。这种写法在小规模没问题但一旦遇到热点活动任意一个下游抖动都会拖垮整个发布接口。合理的评论写入链路应该是这样的客户端 POST/v1/comment带上 target_id、content、parent_id/root_id。API 层做基础校验登录态、内容长度、频率限制。生成评论 ID写 MySQL 状态为“待审核”或“正常”。写出成功后就返回客户端“发布成功”不等待后续动作。发送一条comment.created消息到 MQ。审核服务消费消息做机审和人工审核通过后更新状态。缓存服务消费消息刷新该 target 的评论列表缓存和计数。通知服务消费消息给楼中楼的被回复者推送消息。把写库和发 MQ 放在一个事务里是不可能的因为 MySQL 事务不管理 MQ。常见的补偿方案是本地消息表在同一个 MySQL 事务里插入评论的同时插入一条待发送消息事务提交后由一个定时任务扫表并投递到 MQ投递成功再删除消息。如果觉得本地消息表太重也可以用“发 MQ 失败时记录日志 定时扫描最近 N 分钟新增评论”的方式补投评论系统允许分钟级延迟这种降级完全够用。这里需要明确一个产品决策用户发布评论后要不要立即让对方在自己页面看到我建议做一个特殊处理查询评论列表时如果发现当前登录用户 ID 对应的评论刚发布不久就把这条评论内插到返回列表顶部或原始排序位置即使它实际上还在审核中。这样可以避免用户以为评论丢了反复提交同时又不影响其他用户看到的审核流。4.2 消息队列在评论系统里的真实作用很多人觉得评论系统用 MQ 是为了异步发通知其实它更大的作用是削峰和保护数据库。假设一场盖楼抽奖活动一秒钟涌进来 10 万条评论MySQL 单库单表的并发写入能力通常只有每秒几千硬抗必然把主库写崩。引入 MQ 后评论服务只做校验和写一条消息瞬间完成数据库消费端按照每秒 2000 到 3000 的固定速率慢慢落库整体吞吐不会丢但峰值被削平了。MQ 在评论系统里还有一个容易被忽略的价值它天然把评论的核心写入和下游扩展解耦。今天需要接一个新的审核模型明天需要给评论加语义分析后天要给楼主生成评论摘要都可以直接消费同一份 comment.created 消息完全不用改主链路代码。我见过一些系统为了快速上线把审核、计数更新、通知全部同步写在发布接口里后期每加一个需求都要重新压测发布链路非常痛苦。消费端要注意顺序性。同一条评论的状态变更比如“待审核 → 正常 → 删除”必须按顺序执行否则可能出现先删除后审核通过的错乱。解决方式是让 MQ 生产者按照评论 ID 哈希到固定分区消费者单分区内串行处理既保证单条评论的顺序又不牺牲整体并发。4.3 防刷与限流别让恶意流量打垮主库高性能不只是“扛得住正常流量”还要“拦得住异常流量”。评论系统常见的攻击方式有三种注册机批量灌垃圾评论、同一内容重复提交、短时间内高频刷楼。针对这三种限流策略分别处理。用户维度做令牌桶限流用 Redis 存储用户每分钟的评论配额。比如普通用户每分钟最多 5 条活动期间可以临时提升到 20 条。实现很简单使用 Lua 脚本完成INCR EXPIRE的原子操作避免并发下计数不准。设备/网络维度再做一层限制比如同一 IP 在短时间内不能对应大量不同用户 ID 的评论防止批量注册小号刷楼。内容维度要防重复。盖楼抽奖活动中最常见的就是同一段文本反复刷服务端可以计算内容哈希存到 Redis 里带短过期时间比如 10 分钟如果同一个用户或同一个 target 下再次出现相同哈希直接拦截。更严格的可以用 SimHash 做相似度判断但对称号抽奖场景来说哈希已经够用。全局入口的 QPS 限流放在网关层比如对/v1/comment接口设置集群总 QPS 上限超过后直接返回“当前评论人数过多请稍后再试”。这一步是为了在最坏情况下保护数据库和 MQ而不是靠业务代码兜底。记住一个原则限流宁可误伤也不能让核心数据库被拖垮。5. 高可用设计与压测排查实录5.1 降级策略缓存或数据库不可用时评论如何存活评论系统属于“非核心但很影响体验”的模块所以降级原则是尽量保住读适当放弃写。缓存挂了评论列表直接查 MySQL 主从库但必须在数据库前面加一层回源熔断防止所有流量一瞬间打到数据库。判断依据可以是 MySQL 的错误率或平均延迟超过阈值后停止回源只返回本地缓存里尚未过期的数据并临时把本地缓存 TTL 延长到 5 分钟。这种情况下用户看到的是稍微旧一点的评论列表但接口不会挂。数据库挂了情况更复杂。写评论肯定没法完成但已经发出去的评论能不能读要看缓存是否还在。如果 Redis 正常评论列表和详情都还能服务那么此时可以做“只读降级”关闭发布评论入口前端提示“评论功能维护中”。不要试图用别的存储代替 MySQL那会让数据状态更加混乱。消息队列不可用时评论发布接口不能直接失败。可以降级为同步更新 Redis 缓存和计数MySQL 照常写入但不发通知。等 MQ 恢复后通过定时任务扫描最近一段时间内没有投递成功的评论补发通知和缓存刷新。这个降级方案牺牲的是“即时通知”但保住了用户发布的完整性。降级策略一定要提前演练。我见过很多系统降级开关写了但没人用真到故障时发现开关所在的配置中心也挂了或者降级后代码路径根本没测过。建议每季度做一次故障演练把 Redis 节点、MySQL 从库、MQ 集群分别手动停掉观察系统的表现和监控报警是否符合预期。5.2 压测结果与真实踩坑慢查询、大 Key、GC 抖动压测是检验架构设计最直接的手段。我以一个中型评论系统的压测数据举例应用节点 4 台 8C16GMySQL 一主两从Redis 三主三从用 wrk 对评论列表接口压测。初始版本所有请求直接查 MySQLQPS 到 800 左右 MySQL CPU 就打满接口延迟超过 200ms。接入 Redis 列表缓存后QPS 提升到 8000P99 延迟降到 30ms。继续加本地缓存后QPS 到 20000 时 Redis CPU 只有 30%P99 稳定在 10ms 以内。这个数据只是示意不同的机器规格和数据量会有差异但性能提升的幅度非常有代表性。压测中踩过的坑也值得拿出来说。第一个坑是列表接口的SELECT *。压测时发现 MySQL 的磁盘读和网络出口都不高但接口延迟很大后来用EXPLAIN一查发现 query 里把 TEXT 类型的 content 全查出来了InnoDB 每条数据都要回表读大字段导致磁盘 IO 飙升。改成列表只查轻量字段、详情单独缓存后同样 QPS 下数据库负载直接降了一半。第二个坑是深分页慢查询。有一个用户量很大的帖子累积了 80 万评论运营人员在后端管理后台翻到第 5000 页查看时单条 SQL 执行了 8 秒直接把从库拖垮。改成游标分页并让管理后台也传上一页的游标后同样的查询降到了 20ms。第三个坑是 Redis 大 key。当时为了做“展开全部回复”把一个一级评论下的所有回复 ID 都放到了一个 ZSET 中日常几千条没事后来一个热门评论下面有 20 万条回复ZREVRANGE 0 -1导出的数据体积非常大Redis 慢查询日志天天报警。后来把楼中楼也改成按页分片读取只取当前页 20 条问题立刻消失。第四个坑是主从延迟。评论写入走主库列表读取走从库正常情况下从库延迟几十毫秒没问题。但在高峰期主库写入压力大时从库延迟可能到几秒用户发完评论后刷新看不到自己的评论投诉量暴增。解决办法是详情查询时如果评论时间在最近 10 秒内强制走主库或者写入时把“我刚刚发布的评论”放到当前用户会话的本地缓存里展示时优先合并进列表。第五个坑比较隐蔽是本地缓存导致的 GC 抖动。用了 Caffeine 之后L1 缓存里保存的是完整评论 DTO热点帖子可能存了上千条对象占用堆内存很大。高峰期 Young GC 频率变高进而影响接口延迟。优化方法很简单本地缓存也换成只存轻量的评论摘要对象正文详情需要展示时再走 Redis 或 MySQL 读取。评论正文很少被列表页一次性展示全部摘要对象足够应付大多数场景。收尾一点个人体会评论系统我做了几年最大的感受是别一上来就追求复杂的分布式架构但一定要把读写分离、缓存分层、异步削峰这三件基本功练扎实。盖楼场景真正考验的不是某一个组件有多强而是热点到来时整个链路能不能稳住。如果你们家的评论列表现在也很慢我建议第一件事不是重写架构而是先查慢日志、找深分页、看大 key把这三个问题解决掉系统往往就能从濒死状态救回来。之后再逐步把异步链路和降级开关补上。评论这种业务永远不会完全风平浪静但只要链路清晰、预案到位再大的浪来了也能接住。
返回列表