首页 / 资讯中心 / 文章详情

新闻App评论后端架构演进:从单体到高并发中台的完整实践

新闻App评论后端架构演进:从单体到高并发中台的完整实践 ★ FEATURED ARTICLE
做新闻App的评论后端最刺激的时刻不是新功能上线而是某条突发新闻下面突然涌进来几十万条评论然后眼睁睁看着接口耗时从80毫秒飙到8秒。这个场景我经历过太多次。这些年从最初一个Tomcat加一张表撑起全部评论功能到后来拆缓存、上队列、分库分表再到今天开始琢磨怎么用向量检索和模型审核去做体验层面的东西评论系统这个方向其实浓缩了整个后端演进的大部分套路。这篇文章就是把我踩过的坑、重构过的方案、以及对未来方向的一些思考按“昨天、今天、明天”的时间线完整梳理一遍。适合正在做内容社区、新闻资讯类App的后端同学尤其是那些想系统了解评论体系从简单到复杂全过程的开发者。1. 昨天单体时代硬扛一切的日子1.1 最朴素的评论模型一张表走天下早期的评论功能在技术层面可以用“简陋”来形容。那时候业务逻辑极简用户看新闻拖到底部输入昵称和内容点提交然后所有人都能看到按发布时间倒序排列的评论列表。后端就是一套标准的Spring MVC加单库单表评论表核心字段无非就是id、news_id、user_id、content、create_time、status这几个再加一个idx_news_time联合索引所有查询都是WHERE news_id ? ORDER BY create_time DESC LIMIT ?。这套方案在日活几万、单条新闻评论量几百条的量级下完全够用连缓存都不用上MySQL一个普通实例轻轻松松扛住。开发效率也高CRUD写完就上线从需求到交付一周搞定运维只需要盯着数据库连接数和慢查询日志就够了。如果产品形态永远停留在那个规模这种架构其实没有啥硬伤问题出在增长速度和突发流量的到来新闻App偏偏是最容易出现流量尖峰的业务形态。1.2 压力测试下的连锁崩溃第一次明显出事是某条社会新闻突然上了热搜。具体的新闻内容现在已经记不清了但我清楚记得那天的监控曲线——接口请求量在半小时内翻了四十倍随后数据库CPU使用率直接打满评论列表接口和发评论接口同时开始超时接着整条业务链路上的其他功能也受到拖累因为所有接口都共用同一组数据库连接池。用户端表现就是评论转圈圈、发出去提示失败但实际上写进去了重试又产生重复数据评论区彻底失控。那段时间的排查过程也很有代表性。我先看慢查询日志发现大量ORDER BY create_time DESC走全表排序因为单新闻评论量太大之后索引选择性变差MySQL优化器经常不选联合索引直接File Sort。再看应用日志连接池耗尽大量Connection is not available异常。再到后来评论区开始出现大量垃圾广告评论审核全靠人工后台一条条看根本看不过来。单体架构下数据库既是存储又是瓶颈所有问题最终都会汇聚到一个点上爆发。1.3 当时能做的有限优化没有一个转折点式的重构真实的演进过程是拆东墙补西墙逐步打补丁。第一批优化措施包括给news_id加独立索引把content字段挪到单独的扩展表以减小行宽针对高并发读的新闻详情页做页面级缓存评论接口加Redis缓存缓存key简单粗暴地用comment:list:{newsId}缓存整页评论JSON写入侧则引入一个简单的内存队列把发评论请求先落库到一个pending表再异步同步到主表降低高峰期的写入压力。这些措施短期内确实有效接口P99耗时从8000毫秒降回800毫秒勉强能用了。但隐患清晰可见缓存和数据库的一致性只是靠超时时间硬扛缓存一过期就打回原形异步写入逻辑里可能出现重复消息系统没有幂等机制本地内存队列在应用重启时会丢数据。说白了这套补丁架构只是把问题往后推了并没有真正解决评论系统的核心矛盾——高并发读、突发写、海量数据存储以及内容安全审查之间互相拉扯的问题。2. 今天模块化改造后的评论中台2.1 重新划分的基础架构痛定思痛之后我们做了一次比较大的架构调整。核心思路是按数据特征把评论系统从单体里拆出来形成独立的评论服务再围绕它搭建基础设施层。整体结构大致分为四层接入层独立的评论网关负责鉴权、限流、参数校验把非法请求挡在最前面。缓存层Redis Cluster作为主缓存搭配进程内本地缓存做二级缓存解决热点问题。存储层主库负责写从库负责读按news_id做分片冷热数据分离存储。异步层Kafka承接评论写入、审核、计数更新等任务削峰填谷。这个改造最大的收益是故障隔离。评论服务挂了不再拖垮新闻详情页主流程这个在业务上是底线级别的要求。另一个重要变化是读写分离评论的写入走主库经过审核之后才对外可见读取走从库加缓存各自扩缩容互不干扰。2.2 评论ID生成分片之后的第一道坎分库分表之后数据库自增ID就不能用了全局唯一ID需要单独解决。我们对比了几种方案最终选了类雪花算法。64位的ID分成时间戳、机器ID、序列号三段单机每毫秒能生成上千个ID完全够用。实现上有一点特别注意序列号部分不能直接取模否则会周期性地产生偶数ID导致某个分片数据倾斜严重。另外时间回拨问题也要处理简单做法是记录上次生成时间发现时间倒退时用预留的序列号空间兜底实在不行就拒绝服务。当时我们没有重复造轮子用的是现成的开源组件本身已经稳定运行很多年。但有个点很少人提——评论ID的生成时机。评论的ID是在用户点击提交时就生成好还是等异步消费到Kafka再生成这个顺序会影响数据一致性。我们最后选择在接入层生成ID同时把ID作为消息的key发到Kafka这样同一个新闻ID下的评论消息天然按照发布时间有序进入同一分区下游消费时简单很多。2.3 缓存三级架构本地缓存才是扛热点的主力新闻App评论流量最典型的特征就是极度集中。一条新闻爆了大家全在看这条新闻下的评论其他新闻几乎无人问津。这种场景下单纯的Redis缓存扛不住因为Redis本身也会成为热点单分片压力过大。我们的解法是三级缓存。第一级是进程内本地缓存如Caffeine过期时间只有30秒到60秒目的是挡住绝大部分读流量第二级才是Redis缓存整页评论列表数据第三级是数据库只有缓存全部失效才回源查询。为什么会先大量使用流量不大的技术点去解决热点原因很简单本地缓存的命中率在热点场景下可能达到99%以上而Redis的单分片QPS上限虽然高但每秒几万次的集中查询也会触发单线程瓶颈。本地缓存有个必须注意的坑新闻详情页是部署在多台机器上的每台机器的本地缓存内容并不同步。如果某条评论刚被管理员删除用户在一台机器上刷新看到已经删除在另一台机器上刷新还能看到残留。解决思路有三种一是缩短过期时间尽快收敛二是通过Redis订阅一个删除事件各节点收到消息后自行淘汰本地缓存三是接受短暂延迟毕竟评论删除的实时性要求没那么高。我们实际选了第二种通过一个轻量的广播机制通知各节点主动失效缓存实测效果不错。2.4 评论排序算法不只是按时间倒序产品上最直观的变化是排序方式。早期只有按发布时间倒序一种排序后来运营团队提出需要一个“热评榜”问题就来了——怎么定义热度单纯按点赞数排会有一个严重问题老评论永远排最前面新评论永远没机会上热评榜整个评论区会越来越死气沉沉。业界比较常见的方案是引入类似Reddit的热度公式融合点赞数、评论时间和评论的回复数。我们的实现大致是这样的热度分 点赞数权重 回复数权重 时间衰减因子。时间衰减非常重要它让评论热度随时间自然退潮。核心代码片段如下public double calcHotScore(long likeCount, long replyCount, long createTime, long now) { double likeWeight Math.log10(Math.max(likeCount, 1)); double replyWeight Math.log10(Math.max(replyCount, 1)) * 1.5; long ageHours Duration.between(Instant.ofEpochMilli(createTime), Instant.ofEpochMilli(now)).toHours(); double decay Math.pow(0.99, ageHours); return (likeWeight replyWeight) * decay; }这个算法有个细节取对数而不是用线性值因为点赞数从1到100的变化远比从10000到10100重要得多对数能消除长尾数据的绝对量级影响。时间衰减因子用指数衰减这样几个小时后热度自然消退但不至于瞬间归零。排序索引上我们用了一个技巧只对最近24小时内产生过点赞或回复的评论实时计算热度其他评论用离线任务批量更新既保证热评榜的时效性又避免每个请求都做全量计算。2.5 消息队列与写路径从同步到最终一致评论写入链路是这次改造里变数最大的部分。旧方案是同步写库用户提交后必须等待数据库写入完成才返回成功高峰期数据库被打满时用户体验非常差。新方案把写路径拆成两条主路径评论正文接入层校验参数后将评论消息发送到Kafka的comment-write主题直接返回“发布中”消息消费后写入主库。旁路计数更新同一条消息同时触发评论数、点赞数的异步更新与正文解耦。消息队列引入了一个新问题用户看到“发布中”之后如果消息消费失败评论可能永久丢失。我们当时的应对措施是消费者关闭自动提交offset成功处理完再手动提交失败的消息进入重试队列退避重试三次仍失败则转入死信队列由定时任务补偿处理再不行就人工介入。消息重复是另一个常见问题Kafka的至少一次投递语义天然会重复消费所以我们在写入时用前面提到的评论ID做唯一约束重复消息会被数据库唯一索引挡住保证幂等。因为异步化的存在评论可见性从强一致变成了最终一致。用户发完评论后可能过几秒才能看到自己的评论出现在列表里。这个产品上是可以接受的但需要注意文案上明确提示避免用户以为自己评论失败了反复提交产生大量重复消息。2.6 热点新闻下的限流与降级方案新闻评论系统必须有预案。我们遇到最高峰的流量是日常的几十倍如果只靠扩容去扛成本上不现实所以限流降级是必选项。网关层我们用了令牌桶算法每个用户对新闻详情页的评论接口有一个独立的速率限制防止单用户无限刷全局限流则按照服务整体容量设定阈值超出的请求直接返回“稍后再试”的提示文案。降级逻辑做了分级。一级降级是关闭热评榜计算只提供时间倒序列表二级降级是关闭评论列表的实时性回到只读缓存三级降级是直接显示一条“评论功能暂不可用”的兜底文案保证新闻主体内容的加载完全不受影响。每级降级都有独立的开关运维可以通过配置中心一键切换不需要发版。这个分级策略在几次真实突发热点中验证过效果比较明显至少保住了新闻主流程的可用性。3. 明天评论体验的精细化运营3.1 审核升级从关键词黑名单到模型辅助评论审核在过去很长一段时间里主要靠关键词匹配加人工抽检。关键词黑名单的问题在于语义千变万化变体词、谐音字、拆分词都能绕过同时误杀严重一些正常评论因为包含某个敏感词而被误判。更麻烦的是新闻评论区大量存在的软色情、广告导流内容根本不是靠关键词能识别的。我们正在尝试的方向是引入预训练语言模型做初筛把评论文本转换成向量先做语义分类识别出广告、辱骂、敏感等常见类别。模型判断为正常的评论直接放行不确定的进入人工审核队列。这背后需要一个高质量的标注数据集我们目前的做法是每天抽一部分历史评论做人工分类把结果回流到训练集形成一个持续优化的循环。这里不能自欺欺人模型也不是万能的总会有漏网之鱼所以用户侧的举报和反馈机制必须做好形成闭环。3.2 个性化评论排序与圈层化“明天”的另一个重要变化可能是个性化排序。同一个新闻下的评论区不同用户看到的排列顺序可能不一样。技术上是给每条评论打一套特征标签比如“硬核技术”、“生活感悟”、“娱乐八卦”再根据用户的阅读历史、点赞历史推断用户当前偏好计算个性化热度分。这个方向我们还在探索阶段选型上考虑用向量数据库做近邻检索输入用户兴趣向量输出一批候选评论。独立于个性化之外评论区也在往圈层化发展。新闻不再只有一个统一评论区可以按地域、按兴趣展开子话题区让用户在更匹配自己身份的圈层里交流。后端对应的模型变化是增加一个sub_forum_id维度缓存、分片、排序都要跟着扩展。3.3 性能再往极致走客户端数据预取与边缘节点移动端体验对评论系统的要求会越来越高。未来可预见的趋势是首屏加载越来越快快到用户无感知。当前App多是滚动到底部才触发评论接口请求这个交互路径天然有延迟。我们计划做的优化是在用户阅读新闻正文的过程中通过预取接口提前把评论区前几页数据拉到本地缓存等用户真正滑动到底部时直接渲染减少一次网络往返。边缘节点也是新的发力方向。评论列表是典型的静态化友好场景正文加评论第一页完全可以缓存到CDN边缘节点由边缘节点承担首次访问的流量回源率控制到极低水平。对时效性要求更高的评论写操作才真正回到中心机房处理读写的物理距离被拉开体验提升明显。4. 问题排查实录与避坑速查4.1 缓存穿透与热点击穿的实战排查某次大促活动前准备时监控发现评论列表接口在流量高峰期每天都会出现几秒的毛刺P99从150毫秒跳到2000毫秒。初步判断是缓存失效导致的数据库穿透但奇怪的是Redis命中率并不低。后来定位发现问题出在大量离线爬虫请求携带的是不存在的news_id这些请求每次都直接打到数据库形成缓存穿透。处理手段是布隆过滤器加空值缓存双管齐下在评论服务启动时把最新N天内的有效news_id加载到布隆过滤器请求进来先过布隆不在集合的直接返回空列表不再查询任何存储同时对于确实有但缓存里暂时没有的key写入一个短TTL的空值缓存防止同key反复击穿。这里有个细节布隆过滤器存在误判率宁可放过几次回源查询也不能把正常新闻误杀所以调参时把误判率设得比较保守。4.2 评论丢失与重复的排查思路有一次线上反馈某条新闻下面出现重复评论查下来发现根因不在消费者逻辑而在接入层的重试机制。客户端因为网络超时自动重试提交每次重试都生成了新的comment_id服务端无法识别这是同一条请求于是写入两条内容相同的评论。这个问题的修复方案是引入客户端生成的请求幂等号服务端通过幂等号去重而不是依赖业务ID业务ID只做唯一主键约束。排查这类异步链路问题我一般会按这个顺序来先看消息端到端延迟再去消费端日志里搜是否有重复消费记录然后检查数据库是否有唯一约束最后看接入层日志确认同一用户同一时间是否提交了多次。链路长的时候日志打点显得尤其重要数据流经的每个节点都要有traceId串联。下面整理一份常见问题速查表都是我实际遇到过并确认过解决方案的场景问题现象根因类型快速定位手段推荐方案评论列表接口耗时飙升缓存穿透Redis命中率下降DB慢查询增加布隆过滤器 空值缓存部分新闻评论显示异常残旧本地缓存不一致多节点返回内容不同订阅删除事件广播失效用户重复看到自己发的内容消息重复消费消费日志出现同一ID唯一索引 消费幂等发评论提示成功但列表没有消息积压或失败Kafka消费延迟重试队列 死信队列补偿热评榜被老评论霸榜排序算法缺陷新评论无曝光时间衰减因子调参突发流量下主接口被拖垮缺降级预案整体RT快速上升分级降级开关4.3 架构迁移过程的三个血泪教训第一个教训是“别过度设计”。第一批重构时我们一上来就上了CQRS架构加读写分离中间件结果开发团队根本驾驭不了半年后只能回退。第二次学聪明了从最简单的独立服务加Redis缓存开始跑稳了再逐步引入消息队列和分库分表。架构没有最好只有当前阶段最合适这个理念我再重复一遍都不为过。第二个教训是“监控必须和业务指标强关联”。只监控CPU、内存、磁盘这些系统指标远远不够评论系统真正需要关注的业务指标是评论发布成功率、评论可见延迟、缓存命中率、热评榜更新延迟、审核队列堆积数。这些指标直观反映用户体验任何架构变更都应该在这些指标上做前后对比。第三个教训是“发布顺序要谨慎”。评论系统的升级涉及数据库变更时一定先把兼容性想清楚。我们的评论表增加字段采用的是先加字段、应用双写、稳定后再切换读路径的灰度策略整个过程持续了两周虽然慢但是没有出现过一次因变更引发的数据事故。做基础设施类项目稳定压倒一切。以我个人经验来说评论后端这个方向特别值得深耕因为它几乎能覆盖后端所有核心问题高并发读、高并发写、数据一致性、大规模存储、内容安全、推荐排序。把一个评论系统做到极致基本上就能解决绝大多数互联网系统会遇到的工程问题。今天这套“昨天今天明天”的演进梳理其实也是我自己经验的完整复盘希望能给走在同样路上的同行一些参考。
阅读完成 · 觉得有帮助?
咨询建站