资讯详情

资讯详情

高并发评论系统架构设计与实践:从Redis到Kafka的盖楼方案

说实话做评论系统最头疼的不是能不能发出去而是发出去之后你怎么把几百万条评论按楼中楼的样子在几百毫秒内端到用户面前。去年我们团队接到一个类抖音的短视频业务评论这块从先能跑升级成必须扛住大V发视频后的第一波冲击我全程参与了架构改造。这篇文章不是教科书式的方案汇总是那段时间踩坑、压测、熬夜改bug之后沉淀下来的一套比较完整的做法。里面有架构取舍、有代码细节、有Kafka和Redis在实际高并发下的真实表现也顺便把面试官常问的那些点串一遍——毕竟这题在简历上出现频率太高了。1. 盖楼系统到底难在哪业务模型决定技术复杂度先把位置摆正。盖楼系统和普通评论系统最大的区别是它有一个楼中楼结构。普通评论是平铺的一个视频下面挂一排评论你要做的只是按时间倒序分页而盖楼系统允许用户对某条评论继续回复回复下面还可以再回复形成一棵树。抖音、微博、B站的评论区本质都是这个模型只是各家对楼层和嵌套深度的规定不太一样。1.1 楼中楼不是简单表关联很多第一次做评论系统的人上来就设计一张表comment_id,parent_id,video_id,content,user_id,create_time。这张表看起来没问题实际上它只适合展示两层的场景。如果用户可以回复三层、四层每次拉取评论时你要递归查这张表层数一深SQL就变成噩梦。现实业务里绝大多数产品不会允许无限嵌套。抖音的限制是相对平铺的B站的楼中楼限制在3层。我的建议是产品设计层面就限死嵌套层数技术上才能在无限递归和用户体验之间找到一个可实现的平衡点。我们当时定的是3层超过3层的回复自动并入上一层变成对楼主评论的追加回复。这个决策直接简化了后面的数据模型和缓存结构。1.2 读多写少但热点极端评论区是一个典型的读多写少场景比例大概在20:1到50:1之间。普通列表页扛这个比例不难评论区难在热点极端——一条爆款视频可以在几十分钟内涌入几十万条评论和上百万次评论阅读而其他99%的视频可能一天只有几十条评论。这就带来一个很现实的问题你的系统如果按平均流量设计爆款来时必然被打挂如果按峰值流量设计平时资源又大量闲置。所以评论系统必须做好分层正常的视频走普通链路爆款视频自动触发特殊保护策略。这个意识要一开始就有不能等线上挂了才补。1.3 三个数字先定目标QPS、TPS、P99延迟做任何高并发系统第一步不是画架构图是定指标。我们当时根据业务预测和历史数据定了三个数字指标目标值说明读QPS30万视频评论区的读取请求峰值写TPS3万用户发评论、回复的请求峰值P99延迟200ms用户打开评论区到看到评论列表这三个数字一旦定下来所有技术选型都有据可依。任何方案拿过来先算笔账能不能扛住30万读、3万写扛不住就是方案不合格或者需要加资源。面试时候被问你系统最高并发量多少背后问的其实就是你有没有想清楚这几个数字。2. 写链路从请求进来到落库每一步都不能白费评论写入是整套系统里最需要抠细节的环节。一个用户点了发布这个请求要经过接入层、应用层、缓存、消息队列、最终落库。任何一步出问题要么丢消息要么用户看到评论不一致。2.1 Web层第一道关卡接入与限流用户请求先打到Nginx层这一层做两件事全局接入和基本的限流。Nginx的limit_req模块可以按IP做请求速率限制但这个粒度太粗因为机房出口IP会误伤大量正常用户。更靠谱的做法是在Nginx层做全局限流按接口维度配置阈值比如发评论接口限制3000 req/s超过的直接返回系统繁忙请稍后再试。限流算法我推荐令牌桶Nginx官方模块就支持。配置大概是这样的limit_req_zone $binary_remote_addr zonecomment_limit:10m rate3000r/s; server { location /api/comment/add { limit_req zonecomment_limit burst500 nodelay; proxy_pass http://comment_backend; } }注意burst500 nodelay这个参数允许瞬间500个请求的突发流量直接放行其余排队。为什么必须加burst因为用户操作是脉冲式的一场直播引流过来前几秒的请求量是平均流量的几倍如果没有burst大量正常请求会被误杀。2.2 写Redis为什么放在写MySQL前面我们的核心写链路是先写Redis再发Kafka最后由消费者异步写MySQL。第一次听到这个方案的人通常会问Redis挂了怎么办数据不丢吗这个问题的核心是评论系统的数据一致性要求是什么评论不像转账用户发一条评论1秒后自己在页面上看到其他人稍微晚一点看到完全是可以接受的。用户感知里只要他自己能看到评论就成功了。所以我们敢把Redis当作第一落点而不是MySQL。具体流程是请求进来先做参数校验、敏感词过滤生成全局唯一comment_id用雪花算法把这条评论写入Redis的盖楼结构这一步保证用户立刻能读到把评论数据封装成消息发送到Kafka返回客户端发布成功Kafka消费者收到消息后异步写入MySQL第3步是关键。如果Redis里写入成功而Kafka发送失败这条评论就停留在内存缓存里MySQL里没有。我们的补偿方式是Redis里每条评论带一个sync_status标志0表示待同步1表示已同步。消费者落库成功后会把这个标志置为1。另外有一个定时任务每隔5分钟扫描Redis中超过2分钟仍未同步的评论重新投递到Kafka。这样就保证了最终一致性。2.3 Kafka异步落库与失败补偿的细节Kafka在这里的角色是削峰填谷。3万TPS的写入如果直接打MySQL主库大概率扛不住。透过Kafka缓冲后消费端可以按照MySQL能承受的速度慢慢落库比如2000条/秒。哪怕Kafka里积压几百万条消息MySQL也不会被打死。但这里有个细节很多人忽略Kafka的消费者并发数不能随便调。我们的评论写入有一个隐含的顺序问题——对同一条评论的回复在展示层应该按时间排列。如果多个消费者同时处理消息可能出现后发的消息先落库导致MySQL里评论顺序错乱虽然展示层以Redis为准但后续从MySQL恢复数据时会乱。解决方案是按video_id哈希到分区。Kafka消息的key用video_id这样同一个视频下的所有评论都进入同一个分区保证了同一视频内的评论顺序。消费者组内每个分区一个线程处理天然有序。配置大概是spring: kafka: producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer consumer: group-id: comment-sync-group enable-auto-commit: false auto-offset-reset: earliest我强烈建议关闭自动提交offsetenable-auto-commit: false改成业务落库成功后再手动提交。虽然这样会带来少量重复消费但配合sync_status标志位做幂等重复消费不会产生脏数据。反之如果开着自动提交消息还没落库offset就动了一旦消费者崩溃那些评论就真丢了。2.4 延迟双删缓存与最终一致性读链路里我们会把评论列表缓存到Redis这就涉及缓存与MySQL的一致性问题。我们的做法是延迟双删更新MySQL之前先删除Redis里的评论列表缓存等待一段时间比如500ms再次删除Redis里的缓存为什么需要两次删除因为第一次删除后可能有一个读请求从MySQL读到旧数据在第二次删除之前把这个旧数据又写回Redis。等500ms再删一次就是为了把这个回写窗口期的脏数据清掉。同时MySQL的binlog可以通过Canal订阅实时变更到Redis。但这套方案底层依赖Canal部署运维成本偏高。如果团队规模不大我建议只靠延迟双删 缓存过期时间兜底评论列表缓存设置5分钟过期已经能覆盖绝大多数场景。3. 读链路百万条评论怎么在100ms内拼出盖楼树读链路的目标是让用户打开评论区那一刻就能看到一个完整的盖楼树并且往下翻页时体验流畅。这里面的技术含量比写链路更高因为树形结构的分页本身就是一个反常规的分页场景。3.1 盖楼树的存储模型Redis zset 嵌套结构我们的Redis存储设计是这样的每个视频的顶层评论放在一个zset里comment:video:{videoId}member是顶层评论IDscore是评论创建时间戳每条顶层评论的楼内回复放在另一个zset里comment:floor:{commentId}member是回复IDscore是回复创建时间戳如果用Jedis连接Redis核心操作是// 发布一条顶层评论 Jedis jedis jedisPool.getResource(); jedis.zadd(comment:video: videoId, System.currentTimeMillis(), String.valueOf(commentId)); // 发布一条楼内回复 jedis.zadd(comment:floor: parentCommentId, System.currentTimeMillis(), String.valueOf(commentId));为什么用zset而不是list因为list只能按插入顺序取一旦中间有删除后续的偏移量就乱了。zset按score排序天然支持范围查询可以随时跳过已删除的评论还支持游标分页。打散大视频的评论时我们用videoId % 1000做了分片避免单个zset变成大key。举个例子comment:video:1002837如果数据量太大就拆成comment:video:1002837:0到comment:video:1002837:999读的时候并行去多个分片取然后再合并排序。这里有个血的教训不要存整个评论对象到zset的member里zset的member去做JSON序列化会让内存爆炸而且无法按单个字段更新。正确做法是member只存评论ID评论的详细内容放到另一个hash结构comment:detail:{commentId}里。读取的时候管道批量拿性能完全够。3.2 游标分页代替offset分页评论分页如果用offset页数越深性能越差。zrangebyscore虽然能跳但APP端下拉刷新需要一个稳定的锚点。我们采用游标分页客户端每次下拉带上上一页最后一条评论的create_time也就是score。服务端用zrangebyscore key (lastScore -inf LIMIT 0 20来取下一页。# 取第一页最新20条 ZREVRANGE comment:video:1002837 0 19 WITHSCORES # 取下一页score小于上一页最后一条的20条 ZREVRANGEBYSCORE comment:video:1002837 (1633832371686 -inf LIMIT 0 20注意(1633832371686的左括号表示不包含这个score本身。这样即使两批请求之间插入了新评论也不会导致已读过的内容重复出现。楼中楼回复的加载则采用按需懒加载策略进入页面时每条顶层评论只展示前3条回复用户点击展开更多回复再异步拉取。这不仅是性能优化更是用户体验策略——如果一个楼里有一万条回复全铺开手机屏幕根本放不下用户也划不动。3.3 热key打散与本地缓存兜底评论系统最大的杀手是热点key。一条大V视频的评论集中在一个zset里读QPS可能是几万甚至十几万单机Redis撑不住。我们的方案是三层Redis集群层面做副本热点key的读请求通过readonly命令均匀分摊到多个副本节点主节点只负责写应用本地缓存Caffeine每个应用节点缓存最近1秒访问量最高的视频评论列表。注意这层缓存必须有极短的过期时间我们设了1秒。过期时间太长会导致评论延迟可见用户会骂热key自动发现在应用层埋点统计每个视频的访问QPS超过阈值比如2000 QPS就自动把它加入本地缓存白名单这套组合拳打下来一个热点视频的读请求只有大约10%会穿透到Redis其余都在本地缓存和Nginx层面返回了。面试官如果问热点key怎么解决你可以直接说出这套方案的核心副本分离读压力本地缓存兜底动态识别热key而不是只会背加缓存三个字。4. 计数系统楼层数、点赞数、回复数的原子性保障评论区的互动元素多点赞数、回复数、楼层数。这些数字有个共同的特点变化极其频繁但单个数字本身没有高并发下的绝对实时需求。用户看到一个点赞数在1秒内有几十个误差完全无感。4.1 用INCR/DECR处理互动计数Redis的INCR和DECR是原子的这是计数器最核心的操作。点赞、取消赞直接对评论的计数key做增减INCR comment:likecount:1002837_88291 DECR comment:likecount:1002837_88291但这里有个问题用户点了一个赞客户端一般不会立刻知道这个评论现在的真实点赞总数是多少它需要一个服务端返回。INCR命令的实现方式是先增后读在高并发下这个读出的值可能包含了其他用户的并发操作导致返回值和实际上报有偏差。我们的做法是点赞接口返回INCR之后的新值给客户端并接受轻微不准确。真正需要精算的方式是使用Lua脚本把INCR和后续操作原子化。下面是一个简单示例local key KEYS[1] local field ARGV[1] local delta tonumber(ARGV[2]) local newVal redis.call(hincrby, key, field, delta) return newVal我们用hash承接计数comment:counter:{commentId}这个hash里存likeCount,replyCount,floorCount等字段用HINCRBY在一个key内原子更新多个计数。4.2 计数器落库与对账Redis里的计数再准长期不落库也不行Redis内存再大也兜不住所有评论的计数。我们的策略是异步批量落库应用层更新Redis计数同时发一条计数变更消息到Kafkatopiccomment-counter-change消费者攒够100条或每隔2秒批量更新MySQL批量更新的时候用UPSERT语句避免每次都查一下存不存在INSERT INTO comment_counter (comment_id, like_count, reply_count) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE like_count like_count VALUES(like_count), reply_count reply_count VALUES(reply_count);这里有个价值极高的对账技巧每天凌晨跑一次全量对账用Redis里的计数和MySQL里的计数做diff差异超过阈值就自动以Redis为准回刷MySQL。这个对账任务看起来笨但它能发现各种数据结构设计导致的隐蔽问题比如消息丢失、重复消费、Lua脚本边界bug。4.3 冷热数据分离评论区有一个天然规律99%的用户访问都集中在最新的几天。三个月前的爆款视频也可能不断有用户点进去看但对于计数系统来说它已经变成冷数据了。我们的策略是计数热点和评论内容热点一致。一个视频如果24小时内没有新的评论写入、且访问QPS低于某个阈值就把它从Redis的计数热点池里移除后续的计数读写直接走MySQL。这样整个计数系统的Redis内存占用可以保持在一个相对恒定的水平不需要为所有历史数据扩容。5. 削峰填谷Kafka在评论高并发里到底承担什么Kafka几乎是评论系统高并发架构里最常被提到的一个组件但很少有文章讲清楚它到底在解决什么问题以及实践中怎么调参。5.1 为什么不用同步双写假设不用Kafka用户发评论时应用层同时写Redis和MySQL。这样看起来简单但问题很明显MySQL的写入速度上限远低于Redis如果直接同步写MySQL3万TPS的写入请求会拖慢整个接口的响应时间用户感知就是发布评论很卡MySQL主库是单点瓶颈即使做分库分表短期内也无法平滑扩到3万TPS同步双写会放大单个组件故障的影响——MySQL抖一下整个发评论功能就不可用Kafka的价值在于把必须同步完成和可以异步完成的两部分拆开。用户真正关心的是评论发出去后能不能马上看到这个由Redis保证而MySQL落库是后台行为用户无感知。Kafka像一个蓄水池上游洪峰到了它先接住下游按照自己的节奏慢慢放。5.2 分区数、消费者并发与消息有序性Kafka的分区数决定了消费者的最大并行度。我们的经验值是分区数 目标消费TPS / 单消费者TPS。如果单消费者能处理2000条/秒目标消费TPS是2万/秒那分区数至少要10个。但分区数不能随便加大因为分区数直接关系到Zookeeper和Broker的元数据管理开销以及同一视频内评论的有序性一个视频只进一个分区。消费者侧的配置有几个容易踩坑的点消费线程数建议和分区数保持一致别搞成2分区10个线程会有大量线程在空转等待消费者拉取批量大小max.poll.records不要设太大500是相对合理的值。太多会导致单次处理时间过长容易触发max.poll.interval.ms超时然后被踢出消费组触发rebalanceacksall配合副本因子对评论数据比较稳妥虽然牺牲了一点吞吐但能保证不丢消息5.3 消息积压时如何扩容评论区一旦有大V集中推广消息积压是必然的。积压不是问题被积压打挂才是问题。我们的处理方式是这样的先观察积压趋势如果只是暂时积压且积压量在下降不用动如果积压持续增长说明消费速度跟不上生产速度优先给消费者扩容——增加消费者实例数量每个实例消费不同的分区如果分区数限制了消费并行度那就得紧急加分区但这会导致之前同一个video的评论乱序。我们的临时策略是加一个临时topic专用消化积压消费完之后再按照videoId重新排序写入主库另外一个重要的维护经验给Kafka配置一个消费者延迟监控会比较踏实。我们用的Prometheus Grafana对每个消费组的consumer_lag做告警。积压超过1万条就报警超过10万条就自动触发扩容流程。这套监控在线上救过我们好几次。6. Web服务器与语言选型JAVA、PHP和Nginx的真实差距看到搜索词里有人问高并发web服务器选择java还是php直接把话题延展到这里。评论系统属于IO密集和内存缓存密集的场景相比纯粹的计算密集语言本身的性能在整体架构中并不是最核心的因素。真正决定系统并发上限的是连接模型和生态配套。6.1 连接模型决定并发上限传统PHP-FPM是一个请求一个进程进程是有内存开销的最大并发数受限于进程数。一台8核16G的服务器跑PHP-FPM默认配置下大概能支撑5001000并发。Java的Spring Boot基于Netty或Tomcat的NIO模型一个线程处理多个连接可以轻松支撑几千并发。这就是为什么大型高并发系统普遍偏向Java而不是传统PHP-FPM。但PHP不是不能做高并发。用Swoole这类常驻内存模型PHP也能实现异步IO和协程并发能力和Java的差距会缩小很多。不过这意味着你要引入一套更复杂的部署和运维方式同时很考验团队的工程化能力。6.2 Java技术栈的标配组合我们的评论系统主体是Java Spring Boot原因很直接Spring Boot生态成熟Redis、Kafka、MySQL的客户端封装都是开箱即用Netty性能好Tomcat的NIO调优有很多真实案例可以参考JVM层面的调优G1GC、堆内存分配大家踩坑经验多出了问题容易找到人问一个典型的Tomcat配置server: port: 8080 tomcat: threads: max: 800 min-spare: 200 accept-count: 1000 max-connections: 20000这套配置配合理想的Redis和MySQL资源单机扛20003000 QPS问题不大。我们的部署是按2个节点起步横向扩容。6.3 PHP能扛高并发吗回到Java还是PHP这个问题我的结论是如果你是从零做一套高并发评论系统选Java如果你手里已经有一套PHP技术栈且业务量还没上来不要急着推翻重来。PHP在高并发场景下最大的短板不是语言本身而是传统FPM模型的并发天花板。PHP 8 Swoole可以将单个服务的并发能力拉到2万左右这已经能满足绝大多数评论系统的需求。需要付出的代价是Swoole常驻内存后代码里的全局变量、静态变量、连接句柄都要重新梳理内存泄漏和GC问题也变多了调试难度直线上升。真到了日活千万、评论亿级的体量Java生态里可以参考的现成方案更多比如Spring Cloud、Sentinel、Seata这些招聘市场上会的人也更多。选型本质上是选生态不是选语言。7. 从面试角度复盘这个项目最后聊聊面试。很多人把这类项目写到简历上结果被面试官几句话问穿核心原因是只准备了方案没准备为什么。7.1 面试官问最高并发量时想听什么这个问题看起来简单实际上有三个层次第一层你能说出一个经过压测的数字比如读30万QPS第二层你能说清楚这个数字是怎么测出来的用了什么压测工具服务器配置是什么压测结果里瓶颈在哪第三层你能说出这个数字对整个架构设计的约束——比如同样一个系统如果读QPS从30万涨到100万你要改什么我面过很多人能答到第二层的已经是少数第三层基本是高级工程师的门槛。面试官问最高并发量真正的意图是看你对系统瓶颈有没有清晰的判断力而不是看你嘴里的数字有多唬人。7.2 关键技术方案的表述方式如果你的简历写了评论系统面试官大概率会问以下几个问题评论的盖楼树是怎么存储的——不要只答Redis要说明为什么用zset为什么分片分页为什么用游标为什么用Kafka——要说明削峰填谷、解耦、异步化同时说清楚Kafka引入后的新问题消息积压、重复消费、顺序性和你的解决方案评论数据怎么保证不丢——Redis宕机、Kafka宕机、MySQL宕机三种CASE分别怎么处理缓存和数据库一致性问题怎么解决——延迟双删、Canal订阅、最终一致性热点key怎么办——本地缓存、副本集群、动态识别每个问题都要有产生问题-分析原因-改造方案-效果验证的逻辑链条面试官要听的是你的决策过程不是背答案。7.3 最容易暴露问题的地方我复盘自己面人的经验发现评论系统项目最容易在三个细节上露馅分页细节如果候选人说用MySQL的limit offset分页我会追问深翻页性能问题很多人答不上来消息可靠性只要说用了Kafka所以不丢消息的基本可以判定是背的。Kafka同样会丢消息关键看你怎么配置和使用实时性标准如果候选人说用户发完评论立刻能看到其他人也立刻能看到我要么觉得他没做过高并发要么觉得他对CAP理论没概念。实际上Redis缓存层的刷新本身就需要时间其他人的立刻是秒级就已经很不错了能把这三点说清楚面试基本就稳了。我在实际开发中最深的一点体会是评论系统的高并发挑战本质不是某一个组件的性能问题而是多组件协作、每个环节都有一点延迟、必须靠异步化和缓存把延迟藏起来的系统性问题。所以做这套系统最强的武器不是某个中间件用得多溜而是先把每个环节的延迟和吞吐量摸清楚的意识。比如Redis单分片能撑多少QPS、Kafka生产端并发能拉多高、MySQL批量写入的峰值在哪——这些数字你心里有数设计出的架构才真正经得起线上考验。如果看完这篇你也打算搞一套建议先从压测工具开始把自己的系统打挂一次你会比看十篇文章收获更多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →