架构设计之评论系统

功能模块

架构设计最重要的就是理解整个产品体系在系统中的定位。搞清楚系统背后的背景,才能做出最佳的设计和抽象。不要做需求的翻译机,先理解业务背后的本质,事情的初衷。

评论系统,往小里做,就是视频评论系统,往大做就是评论平台,可以接入各种业务形态。

评论的主要功能:

  • 发布评论:支持回复楼层、楼中楼。(一般会做两层效果较好,但是设计上可以支持无限嵌套)
  • 读取评论:按照时间、热度排序
  • 删除评论:用户删除、作者删除
  • 管理评论:作者置顶、后台运营管理(搜索、删除、审核等)

在动手设计前,反复思考,真正编码的时间只有5%。

架构设计

在做架构设计之前,可以先将大的模块设计出来,并且处理好流量方向。

image-20230926094930746

  • BFF:comment

    复杂评论业务的服务编排,比如访问账号服务进行等级判定(糅杂一些业务规则,例如 account-servicefilter-service),同时需要在 BFF 面向 移动端/WEB 场景来设计 API,这一层抽象把评论的本身的内容列表处理(加载、分页、排序等)进行了隔离,关注在业务平台化逻辑上。这一层专注于处理平台业务逻辑。

  • Service:comment-service

    服务层,去平台业务的逻辑,专注在评论功能的 API 实现上,比如发布、读取、删除等,关注在稳定性、可用性上,这样让上游可以灵活组织逻辑,把基础能力和业务能力剥离。这一层专注于处理数据本身。

  • Job:comment-job

    消息队列的最大用途是削峰处理。当写入请求非常大的时候,通过异步消息队列处理写请求。

  • Admin:comment-admin

    管理平台,按照安全等级划分服务,尤其划分运营平台,他们会共享服务层的存储层(MySQL、Redis)。运营体系的数据大量都是检索,我们使用 Canal 进行同步到 ES 中,整个业务的展示都是通过 ES,再通过业务主键更新业务数据层,这样运营端的查询压力就下放给了独立的 fulltext search系统。(运营后台搜索、统计等,这种业务不适合使用 OLTP,使用 ES 更加适合。)

  • Dependency:account-servicefilter-service

    整个评论服务还会依赖一些外部 gRPC 服务,统一的平台业务逻辑在 comment BFF 层收敛,这里 account-service 主要是账号服务,filter-service 是忙按此过滤服务。

架构设计等同于数据设计,梳理清楚数据的走向和逻辑。尽量避免环形依赖、数据双向请求等。

comment-service

评论服务,专注在评论数据处理。(将 DDD 思想分隔开,作为独立的服务。Separation of Concerns)

最开始的时候,是将 comment-servicecomment 是一层,业务耦合和功能耦合在一起,非常不利于迭代。当然,在设计层面可以考虑目录结构进行拆分,但是从架构层次来说,迭代隔离是更好的。将两个服务拆分开,注重不同的核心点,BFF 关注业务逻辑,comment-service关注数据读写处理。

image-20231012102737583

  • 读的核心逻辑

    Cache-Aside 模式,先读取缓存,再读取存储。

    早期 cache rebuild 是做到服务里的,对于重建逻辑,一般会使用 read ahead 的思路。即预读,用户访问了第一页,很有可能访问第二页,所以缓存会超前加载,避免频繁 cache miss

    当缓存抖动,特别容易引起集群 thundering herd现象(线程惊群效应),大量的请求会触发 cache rebuild,大量往 MySQL获取数据,并且回填到 Redis中。因为使用了预加载,容易导致服务 OOM

    所以再到回源的逻辑里,改为使用消息队列来进行逻辑异步化,对于当前请求只返回 MySQL 中部分数据即止,然后发送异步消息,处理 cache miss的数据和预读的数据到 Redis

  • 写的核心逻辑

    如果发生类似“明星出轨”等热点事件的发生,而且写和读相比较,写可以认为是透穿到存储层的,系统的瓶颈往往就来自于存储层,或者有状态层。

    对于写的设计上,我们认为刚发布的评论有极短的延迟(通常小于几ms),对用户可见是可接受的,把对存储的直接冲击下放到消息队列,按照消息反压的思路,即如果存储 latency 升高,消费能力就下降,自然消息容易堆积,系统始终以最大化方式消费。

    Kafka 存在 partition 概念,可以认为是物理上的一个小队列,一个 topic 是由一组 partition 组成的,所以 Kafka 的吞吐模型理解为:全局并行,局部串行的生产消费方式。对于入队的消息,可以按照 hash(comment_subject) % N(partitions)的方式进行分发,那么某个 partition 中的评论主题的数据一定都在一起,这样方便串行消费。

    同样的,处理回源消息也是类似的思路。

comment-admin

运营后台的读写能力。

MySQL binlog 中的数据被 canal 中间件流式消费,获取到业务的原始 CRUD 操作,需要回放录入到 ES 中,但是 ES 中的数据最终是面向运营体系提供服务能力,需要检索的数据维度比较多,在入 ES 前需要做一个异构的 joiner,把单表变宽预处理好 join 逻辑,然后倒入到 ES 中。

image-20230926110511188

一般来说,运营后台的检索条件都是组合的,使用 ES 的好处是避免依赖 MySQL 来做多条件组合检索,同时 MySQL 毕竟是 OLTP 面向线上联机事务处理的。通过冗余数据的方式,使用其他引擎来实现。

ES 一般会存储检索、展示、primary key 等数据,当操作编辑的时候,找到记录的 primary key,最后交由 comment-admin 进行运营测的 CRUD 操作。

内部运营体系,基于ES来完成更加有优势。

comment

comment 作为 BFF,是面向端,面向平台,面向业务组合的服务。所以平台扩展的能力,都在 comment 服务来实现,方便统一和准入平台,以统一的接口形式提供平台化的能力。

image-20231008175707792
  • 依赖其他 gRPC 服务,整合统一平台测的逻辑(比如发布评论用户等级限定)
  • 直接向端上提供接口,提供数据的读写接口,甚至可以整合端上,提供统一的端上 SDK
  • 需要对非核心依赖的 gRPC 服务进行降级,当这些服务不稳定时。

存储设计

也就是数据库设计。

image-20230927161249867

  • comment_subject:主题表(例如一个资源、视频、文稿等)

    • 要适配多种资源类型,通过 obj_idobj_type 匹配各种资源类型
    • member_id 记录作者用户 id,这个id不会变化,有利于查询
    • countroot_countall_count 分别代表评论总数,根评论总数,评论+回复总数,可更好的做展示,而不需要每次都 count(*)
    • stat 状态记录这个资源的状态
    • attrs 属性,记录资源属性,而且通过二进制,代表多个属性

    表都应该带有 create_timeupdate_time ,而且一定要有主键,写入的时候使用顺序写,避免出现页分裂。

    为了避免单表过大,通过 hash 分割表。

    尽量使用软删除,或者手动设置是否隐藏字段。

  • comment_index:索引表

    • 记录评论的索引
    • 同样记录对应的主题,方便后续查询
    • 通过 rootparent 记录是否是根评论以及子评论的上级
    • floor 记录评论层级,也需要更新主题表中的楼层数,
  • comment_content:评论内容表

    记录核心评论的内容,避免检索的时候内容过多导致效率低。

数据写入:事务更新 comment_subjectcomment_indexcomment_content 三张表。content 属于非强制需要一致性考虑的。可以先写入 content,之后事务更新其他表。即便 content 先成功,后续失败仅仅存在一条 ghost 数据。

数据读取:基于 obj_id + obj_typecomment_index 表找到评论列表, WHERE root = 0 ORDER BY floor。之后根据 comment_indexid 字段获取出 comment_content 的评论内容。对于二级的子楼层, WHERE parent/root IN (id...)

因为产品形态上只存在二级列表,因此只需要迭代查询两次即可。对于嵌套层次多的,产品上,可以通过二次点击支持。

可以看到,上面的存储结构比较简单,而且在这种读多写少的场景,有其他的数据库也支持。例如 ES 也可以,不分表。

Graph 存储也可以, DGraph、HugeGraph 类似的图存储思路,不分表,内部可以实现数据补全。

索引内容分离

用于检索的索引,和评论的具体内容分离成两张表。

comment_index:评论楼层的索引组织表,实际并不包含内容。comment_content:评论内容表,包含评论的具体内容。其中 comment_indexid 字段和 comment_content 是 1对1 的关系,这里面包含几种设计思想:

  • 表都有主键,即 cluster index,是物理组织形式存放的,comment_content 没有 id,是为了减少一次二级索引查找(回表),直接基于主键检索,同时 comment_id 在写入要尽可能的顺序自增。
  • 索引、内容分离,方便 mysql datapage 缓存更多的 row,如果和 content 耦合,会导致更大的 IO,长远来看 content 信息可以直接使用 KV storage存储。

缓存设计

image-20230927174051328

comment_subject_cache:对应主题的缓存,value 使用 protobuf 序列化的方式存入。现在使用 memcache 来进行缓存,或者使用 redis 这种KV键值对存储都差不多。

comment_index_cache:使用 redis sortedset 进行索引的缓存,索引即数据的组织顺序,而非数据内容。

参考百度贴吧,他们使用自己研发的拉链存储来组织索引。

使用mysql 作为主力存储,利用 redis 来做加速完全足够,因为 cache miss 的构建,使用 kafka 的消费者中处理,预加载少量数据,通过增量加载的方式主键预热填充缓存,而 redis sortedset skiplist 的实现,可以做到 O(logN) + O(M) 的时间复杂度,效率很高。

过期时间可以通过内存大小调整。

sorted set 是要增量追加的,因此必须判定 key 存在,才能 zadd

comment_content_cache:对应评论内存数据,使用 protobuf 序列化的方式存入。类似的早期使用 memcache 进行缓存,或者使用 redis 存储。

增量加载(将数据库中的数据批量放到 redis 中,而且先续约时间,然后再加缓存。) + lazy 加载。(所以没有分页,都是使用懒加载,使用 mget 批量获取。)

还可以使用预加载,获取第一页时,将第二页的数据加入到缓存中。

可用性设计

Singlefilght

对于热门的主题,如果存在缓存穿透(缓存中没有数据,请求穿透了缓存,直接打到数据库)的情况,会导致大量的同进程、跨进程的数据回源到存储层,可能会引起存储过载的情况,如何只交给同进程内,一个人去做加载存储。

使用归并回源的思路:

singleflight

image-20231007113032203

同进程只交给一个人去获取 mysql 数据,然后批量返回。同时这个 lease owner 投递一个 kafka 消息,做 index cacherecovery 操作。这样可以大大减少 mysql 的压力,以及大量穿透导致的密集写 kafka 的问题。

更进一步的,后续连续的请求,仍然可能会短时 cache miss ,我们可以在进程内设置了一个 short-lived flag,标记最近有一个人投递了 cache rebuild 的消息,直接 drop

可以看到,这里说明的都是单进程下的解决思路。那么在多进程下,能否使用分布式锁来解决。理论上可以,但是实际操作起来,容易将这个简单问题复杂化,不推荐使用分布式锁。(PS:redis 作者不推荐使用 redis 实现分布式锁。)

多进程下,也是一样的思想,多个进程会发送多个消息到消息队列中,消费端获取消息的时候,通过单飞的思路,同样处理。

热点

热点分为写热点和读热点。

写操作一般会通过MQ削峰,当大量的请求都集中在 MQ 中,不仅仅会影响当前服务,还可能导致下游服务出现异常。这种情况下,可以再进行解耦,增加上游服务的吞吐,将下游服务解耦,不依赖同一个同步逻辑。

流量热点是因为突然热门的主题,被高频次的访问,因为底层的 cache 设计,一般是按照主题 key 进行一致性 hash 来进行分片,但是热点 key 一定命中某一个节点,这时 remote cache 可能会变成瓶颈。因此做 cache 升级 local cache 是有必要的,一般使用单进程自适应发现热点的思路,附加一个短时的 ttl local cache,可以在进程内吞掉大量的读请求。

image-20231007174023897

在内存中使用 hashmap 统计每个 key 的访问频次,这里可以使用滑动窗口(左角标和右角标一起移动,统计区间内部的数据量)统计,即每个窗口中,维护一个 hashmap,之后统计所有未过去的 bucket,汇总所有 key 的数据。

之后使用小堆计算 TopK 的数据,自动进行热点识别。

数据库层级结构设计

很多时候会碰到需要设计带有层级结构的数据,例如部门和成员、树级结构、评论、聊天等,一般会有要求检索某个节点下的子节点。

这种情况下,可以有根节点、叶子节点、中间节点。

  • 邻接表 Adjacency List

    通过 parent_id 记录层级关系

    缺点:

    • 难以查询全部子节点
    • 难以查询特定层的节点
    • 难以查询聚合函数
  • 分段式 Path

    通过 /a/b/c 的形式(或者使用 # 分隔符 _

    缺点:

    • 查找主要依赖于 LIKE 查询
    • 无法保证 PATH 正确
    • Path 依赖于应用的字符串处理
  • Nested Set

    记录左节点和右节点。

    nsleft 小于所有子节点的 nsleft

    nlright 大于所有子节点的 nright

    本质上是一个深度优先遍历的顺序

    缺点:

    • 查找父亲非常困难:c 结点的父亲节 点是祖先节点里面,没有别的祖先节 点作为其子节点的节点
    • 插入非常困难:需要重新计算节点的 nsleft 和 nsright
  • Closure Table

    用单独的表来存储节点之间的关系

对比

image-20231012113811261

Resursive Query 直接依赖于数据库特性。

缓存模式 cache pattern

  • cache aside

    把 cache 当成一个普通的数据源

    更新 cache 和 DB 都是依赖于开发者自己写代码

    cache 读不到,就去读取数据库,需要注意穿透,也需要使用 singlefilght

    先写数据库,然后删除原本的 cache(这是数据不一致间隔最短的一种方式)

  • read through

    从 cache 中读取数据,cache 会在缓存不命中的时候读取数据

    更新数据的时候也直接更新DB,等待 Cache 过期,也可以更新 DB 之后同步更新 Cache

  • write through

    只需要写入 cache ,由 cache 更新数据库

    在读未命中缓存的情况下,需要去数据库捞数据,然后更新 cache

  • refresh-ahead

    依赖于 CDC (changed data capture)接口:

    • 数据库暴露数据变更接口
    • cache 或者第四方在监听到数据变更之后自动更新数据。

总结对比:

image-20231012114740809

推荐阅读

Scaling Memcache at Facebook

Scaling Memcache at Facebook (2013) 中文

SQL Antipatterns :Chapter 3: Naive Trees

A Hitchhiker’s Guide to Caching Patterns

一文吃透 Go 语言解密之接口 interface