<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>评论 on Ther 的博客</title>
        <link>https://blog.ther.cool/tags/%E8%AF%84%E8%AE%BA/</link>
        <description>Recent content in 评论 on Ther 的博客</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>en-us</language>
        <lastBuildDate>Sat, 16 Jul 2022 22:05:41 +0800</lastBuildDate><atom:link href="https://blog.ther.cool/tags/%E8%AF%84%E8%AE%BA/index.xml" rel="self" type="application/rss+xml" /><item>
        <title>评论系统架构设计</title>
        <link>https://blog.ther.cool/posts/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/</link>
        <pubDate>Sat, 16 Jul 2022 22:05:41 +0800</pubDate>
        
        <guid>https://blog.ther.cool/posts/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/</guid>
        <description>&lt;h2 id=&#34;功能模块&#34;&gt;功能模块&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;发布评论: 支持回复楼层、楼中楼。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;读取评论: 按照时间、热度排序。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;删除评论: 用户删除、作者删除。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;管理评论: 作者置顶、后台运营管理(搜索、删除、审核等)。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;架构设计&#34;&gt;架构设计&lt;/h2&gt;
&lt;p&gt;架构设计等同于数据设计，梳理清楚数据的走向和逻辑。尽量&lt;strong&gt;避免环形依赖&lt;/strong&gt;、&lt;strong&gt;数据双向请求&lt;/strong&gt;等。&lt;/p&gt;
&lt;p&gt;概览图如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2022/07/16/UZyRVMrTXdxPNHs.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
		alt=&#34;image-20220716200753155&#34;
	
	
&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;BFF: comment&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;复杂评论业务的服务编排，比如访问账号服务进行等级判定，同时需要在 BFF 面向移动端/WEB场景来设计 API，这一层抽象把评论的本身的内容列表处理(加载、分页、排序等)进行了隔离，关注在业务平台化逻辑上。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Service: comment-service&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;服务层，去平台业务的逻辑，专注在评论功能的 API 实现上，比如发布、读取、删除等，关注在稳定性、可用性上，这样让上游可以灵活组织逻辑把基础能力和业务能力剥离。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Job: comment-job&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;消息队列的最大用途是削峰处理。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Admin: comment-admin&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;管理平台，按照安全等级划分服务，尤其划分运营平台，它们会共享服务层的存储层(MySQL、Redis)。运营体系的数据大量都是检索，使用 Canal 将 MySQL 中的数据同步到 ES 中，整个数据的展示都是通过 ES，再通过业务主键更新业务数据层，这样运营端的查询压力就下方给了独立的 fulltext search 系统。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dependency: account-service、filter-service&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;评论服务还会依赖一些外部 gRPC 服务，统一的平台业务逻辑在 comment BFF 层收敛，这里 account-service 主要是账号服务，filter-service 是敏感词过滤服务。&lt;/p&gt;
&lt;h3 id=&#34;comment-service&#34;&gt;comment-service&lt;/h3&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2022/07/16/eKAs2cDvXox1dbU.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
		alt=&#34;image-20220716201722693&#34;
	
	
&gt;&lt;/p&gt;
&lt;p&gt;comment-service，专注在评论数据处理(认真想下 Separation of Concerns)。&lt;/p&gt;
&lt;p&gt;最开始 comment-service 和 comment 是一层，业务和功能耦合在一起，非常不利于迭代，当然在设计层面可以考虑目录结构进行拆分，但是架构层次来说，迭代隔离也是好的。&lt;/p&gt;
&lt;h4 id=&#34;读的核心逻辑&#34;&gt;读的核心逻辑&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Cache-Aside&lt;/strong&gt; 模式，先读取缓存，再读取存储。早期 cache rebuild 是做到服务里的，对于重建逻辑，一般会使用 read ahead 的思路，即预读，用户访问了第一页，很有可能访问第二页，所以缓存会超前加载，避免频繁 cache miss。但是当缓存抖动时，这种做法特别容易引起集群 thundering herd 现象，大量的请求会触发 cache rebuild，因为使用了预加载，容易导致加载大量数据到服务中，最终导致 OOM。&lt;/p&gt;
&lt;p&gt;所以回源的逻辑使用了消息队列来进行异步化。当缓存 miss 时，对于当前请求只返回 MySql 中部分数据即止，同时向 KafKa 中发送(回源)指令消息。&lt;/p&gt;
&lt;h4 id=&#34;写的核心逻辑&#34;&gt;写的核心逻辑&lt;/h4&gt;
&lt;p&gt;写和读相比较，写可以认为是透穿到存储层的，系统的瓶颈往往就来自于存储层，或者说是有状态层。对于写的设计上，可以认为刚发布的评论有极短的延迟(通常小于几 ms)对用户可见是可接受的，把对存储的直接冲击下放到消息队列，按照&lt;strong&gt;消息反压&lt;/strong&gt;的思路，即如果存储 latency 升高，消费能力就下降，自然消息容易堆积，系统始终以最大化方式消费。&lt;/p&gt;
&lt;p&gt;Kafka 存在 partition 概念，可以认为是物理上的一个小队列，一个 topic 由一组 partition 组成，所以 Kafka 的吞吐模型理解为：全局并行，局部串行的生产消费方式。对于入队的消息，可以按照 &lt;code&gt;hash(comment_subject) % N(partitions)&lt;/code&gt; 的方式进行分发。那么某个 partition 中的评论主题的数据一定都在一起，这样方便进行串行消费。在更新时，先更新 DB，再更新缓存。&lt;/p&gt;
&lt;p&gt;同样的，处理回源消息也是类似的思路。接收到消息后，可以先判断下缓存是否已经 rebuild，如果是则直接返回消息处理成功。&lt;/p&gt;
&lt;h3 id=&#34;comment-admin&#34;&gt;comment-admin&lt;/h3&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2022/07/16/hQOptoklDLRbeB8.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
		alt=&#34;image-20220716204038573&#34;
	
	
&gt;&lt;/p&gt;
&lt;p&gt;MySQL binlog 中的数据被 Canal 中间件流式消费，获取到业务的原始 CUD 操作(没有Retrive)，需要回放写入到 es 中，但是 es 中的数据最终是面向运营体系提供服务能力，需要检索的数据维度比较多，在写入 es 前需要做一个异构的 joiner，把单表变宽，预处理好 join 逻辑，然后写入到 es中。&lt;/p&gt;
&lt;p&gt;一般来说，运营后台的检索条件都是组合的，使用 es 的好处是避免依赖 mysql 来做多条件组合检索，同时 mysql 毕竟是 OLTP 面向线上联机事务处理的。通过冗余数据的方式，将多条件组合检索需求通过其他引擎来实现。&lt;/p&gt;
&lt;p&gt;此外 es 一般会存储检索、展示 primary key 等数据，当操作编辑的时候，找到记录的 primary key，交由 comment-admin 进行运营侧的 CRUD 操作。写操作对 DB 进行操作，经由 Canal 同步至 ES，而不是直接操作写 ES。&lt;/p&gt;
&lt;h3 id=&#34;comment&#34;&gt;comment&lt;/h3&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2022/07/16/5VEWsuA2yh1qIDr.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
		alt=&#34;image-20220716204817745&#34;
	
	
&gt;&lt;/p&gt;
&lt;p&gt;comment 作为 BFF，是面向端、平台、业务组合的服务。所以平台扩展的能力都在 comment 服务实现，方便以统一的接口形式提供平台化的能力。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;依赖其他 gRPC 服务，整合统一平台测的逻辑(比如发布评论用户等级限定)。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;直接向端上提供接口，提供数据的读写接口，甚至可以整合端上，提供统一的端上 SDK。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;需要在非核心依赖的 gRPC 服务不稳定时进行降级。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;存储设计&#34;&gt;存储设计&lt;/h2&gt;
&lt;h3 id=&#34;数据库设计&#34;&gt;数据库设计&lt;/h3&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2022/07/16/u4PwJjbIzds2YLV.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
		alt=&#34;image-20220716221909268&#34;
	
	
&gt;&lt;/p&gt;
&lt;h4 id=&#34;数据写入&#34;&gt;数据写入&lt;/h4&gt;
&lt;p&gt;事务更新 comment_subject，comment_index，comment_content 三张表，其中 content 属于非强制需要一致性考虑的。可以先写入 content，之后事务更新其他表。即便 content 先成功，后续失败，也仅存在一条 ghost 数据。&lt;/p&gt;
&lt;h4 id=&#34;数据读取&#34;&gt;数据读取&lt;/h4&gt;
&lt;p&gt;基于 obj_id + obj_type 在 comment_index 表找到评论列表，WHERE root = 0 ORDER BY floor。之后根据 comment_index 的 id 字段捞出 comment_content 的评论内容。对于二级的子楼层，WHERE parent/root IN (id&amp;hellip;)。&lt;/p&gt;
&lt;p&gt;因为产品形态上只存在二级列表，因此只需要迭代查询两次即可。对于嵌套层次多的，产品上，可以通过二次点击支持。&lt;/p&gt;
&lt;p&gt;Tips: 是不是可以使用 Graph 存储？DGraph、HugeGraph 类似的图存储思路。&lt;/p&gt;
&lt;h4 id=&#34;索引与内容分离&#34;&gt;索引与内容分离&lt;/h4&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2022/07/30/bkxO2q6iTB5Wnyc.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
		alt=&#34;image-20220717182945045&#34;
	
	
&gt;&lt;/p&gt;
&lt;p&gt;comment_index: 评论楼层的索引组织表，实际并不包含内容。
comment_content: 评论内容的表，包含评论的具体内容。其中 comment_index 的 id 字段和 comment_content 是 1 对 1 的关系，这里面包含几种设计思想。&lt;/p&gt;
&lt;p&gt;表都有主键，即 cluster index，是物理组织形式存放的，comment_content 没有 id，是为了减少一次二级索引查找，直接基于主键检索，同时 comment_id 在写入要尽可能的顺序自增。&lt;/p&gt;
&lt;p&gt;索引、内容分离，方便 MySQL datapage 缓存更多的 row ，如果和 context 耦合，会导致更大的  IO。长远来看 content 信息可以直接使用 KV storage 存储。&lt;/p&gt;
&lt;h3 id=&#34;缓存设计&#34;&gt;缓存设计&lt;/h3&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2022/07/17/7ArYIHQ21il6Gtg.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
		alt=&#34;image-20220717225941315&#34;
	
	
&gt;&lt;/p&gt;
&lt;p&gt;comment_subject_cache: 对应主题的缓存，value 使用 protobuf 序列化的方式存入。早期使用 memcache 来进行缓存，因为 Redis 早期单线程模型，吞吐能力不高。&lt;/p&gt;
&lt;p&gt;comment_index_cache: 使用 Redis sortedset 进行索引的缓存，索引即数据的组织顺序，而非数据内容。参考过百度的贴吧，他们使用自己研发的拉链存储来组织索引，我认为 mysql 作为主力存储，利用 redis 来做加速完全足够，因为 cache miss 的构建，我们前面讲过使用 kafka 的消费者中处理，预加载少量数据，通过增量加载的方式逐渐预热填充缓存，而 redis sortedset skiplist 的实现，可以做到 &lt;code&gt;O(logN) + O(M)&lt;/code&gt; 的时间复杂度，效率很高。&lt;/p&gt;
&lt;p&gt;sorted set 是要增量追加的，因此必须判定 key 存在，才能 zdd。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;为什么判定 key 存在后才能 add？&lt;/p&gt;
&lt;p&gt;若直接 add，则可能 key 已不存在，结果就是只 add 了要追加的一部分。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;comment_content_cache: 对应评论内容数据，使用 protobuf 序列化的方式存入。类似的我们早期使用 memcache 进行缓存。&lt;/p&gt;
&lt;p&gt;增量加载 + lazy 加载。&lt;/p&gt;
&lt;h2 id=&#34;可用性设计&#34;&gt;可用性设计&lt;/h2&gt;
&lt;h3 id=&#34;singleflight&#34;&gt;Singleflight&lt;/h3&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2022/07/17/y4qRxKWImXkAvpg.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
		alt=&#34;image-20220717230755891&#34;
	
	
&gt;&lt;/p&gt;
&lt;p&gt;对于热门的主题，如果存在缓存穿透的情况，会导致大量的同进程、跨进程的数据回源到存储层，可能会引起存储过载的情况，如何只交给同进程内，一个人去做加载存储？&lt;/p&gt;
&lt;p&gt;使用归并回源的思路:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;https://pkg.go.dev/golang.org/x/sync/singleflight&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;同进程只交给一个人去获取 mysql 数据，然后批量返回。同时这个 lease owner 投递一个 kafka 消息，做 index cache 的 recovery 操作。这样可以大大减少 mysql 的压力，以及大量透穿导致的密集写 kafka 的问题。&lt;/p&gt;
&lt;p&gt;更进一步的，后续连续的请求，仍然可能会短时 cache miss，我们可以在进程内设置一个 short-lived flag，标记最近有一个人投递了 cache rebuild 的消息，直接 drop。&lt;/p&gt;
&lt;p&gt;为什么我们不用分布式锁之类的思路？&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;分布式锁不好做，且太重。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;热点&#34;&gt;热点&lt;/h3&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2022/07/17/sHDIyce7glU1V5E.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
		alt=&#34;image-20220717231201889&#34;
	
	
&gt;&lt;/p&gt;
&lt;p&gt;流量热点是因为突然热门的主题，被高频次的访问，因为底层的 cache 设计，一般是按照主题 key 进行一致性 hash 来进行分片，但是热点 key 一定命中某一个节点，这时候 remote cache 可能会变为瓶颈，因此做 cache 的升级 local cache 是有必要的，我们一般使用单进程自适应发现热点的思路，附加一个短时的 ttl local cache，可以在进程内吞掉大量的读请求。&lt;/p&gt;
&lt;p&gt;在内存中使用 hashmap 统计每个 key 的访问频次，这里可以使用滑动窗口统计，即每个窗口中，维护一个 hashmap，之后统计所有未过去的 bucket，汇总所有 key 的数据。之后使用小堆计算 TopK 的数据，自动进行热点识别。&lt;/p&gt;</description>
        </item>
        
    </channel>
</rss>
