系统设计练习 - 设计分布式事件流处理系统

业务背景

设计一个事件流处理平台。需要支持数百万客户端实时写入事件,并允许多个下游服务实时消费这些事件。典型应用场景包括:

  • 用户点击流

  • 视频观看事件

  • 广告曝光事件

  • 。。。

系统需求

功能需求

  • 支持写入事件。延迟低,写入顺序可维护,支持持续流式写入。

  • 支持读取事件。多个consumer之间互不影响。

  • 支持consumer group。多个consumer组成consumer group。系统自动分配partition。要求同一个partition同一时刻只能被group中的一个consumer消费。自动负载均衡。

  • 支持消费位置管理(offset)。consumer可以记录自己的消费位置。

  • 支持信息保留(retention)

  • 支持消息重放

  • 支持消息顺序

  • 支持消息过滤

  • 支持批量读取

  • 支持死信队列(DLQ)

非功能需求

  • 高吞吐量

  • 低写入延迟

  • 高可用

  • 持久化

  • 支持水平扩展

api设计

  • 发送事件:

POST: /event

request body:

复制代码
{
    "id": "event12345",
    "type": "video-click",
    "attributes": [
        {
            "name": "user_id",
            "value": "user123"
        },
        {
            "name": "video_id",
            "value": "video456
        }
    ],
    "timestamp": 12345664,
    "event-source": "client789"
}
  • 读取事件:

GET /event?topic=abc&cursor=abcd

系统架构设计

系统分为两个部分:写入事件和消费事件。

我们首先设计写入事件。首先,我们处理的是一个需要保持事件顺序的事件流系统。因此我们的核心数据存储使用基于logs的顺序存储数据。也就是所有的事件只能append到当前log的尾部,不允许对已经写入的log做修改。其次,我们要支持高并发的写,所以我们需要引入partition的概念。我们通过event ID计算它的partition key,然后将写入的请求交由相关的broker进行处理。每个broker维护一个partition的写入。这样同一个partition内部的event可以保持写入严格有序。不同partition之间的顺序默认是不能保证的,因此需要用户设计event ID来满足他的业务需求。这部分的系统架构如下:

其次,我们设计消费事件的逻辑。这里我们引入consumer group的概念。即消费同一个topic的多个consumer被编为一个组,这样它们可以并行消费不同partition的数据,从而增加consumer group的整体throughput。另外,为了维护对于每一个partition消费的offset,我们需要引入一个meta DB,保存每个consumer group在每一个partition上已经消费的offset。整体框架图如下:

利用offset我们可以支持消息的replay。

讨论

  1. 为什么使用logs而不使用数据库作为broker内部的数据存储服务:数据库的优势在于随机写数据,修改数据,和读数据。但是我们系统的要求是按照事件顺序进行读写。logs作为只可以增加而不能修改以前的数据的存储结构,完全满足我们的要求,同时读取效率会大大优于数据库。

  2. partition数据冗余设计:为了保持系统的fault tolerance,同时也为了增强系统的读的scalability,我们考虑给partition数据进行冗余,也就是设计replica。我们需要为每个parition设计一个leader的角色,一个leader可以有一个到多个follower。示意图如下所示:

leader作为一个partition的interface。这里有两种可供选择的同步机制:一种是全部follower的数据都同步完成了才确认一次partition的更新完成;一种是基于quorum,也就是当一次读操作需要访问的node数和一次写操作需要访问的node数的和大于partition的node数量,既可以认为一个partition的更新完成。前一种同步机制会造成写的时延更长,但是读的时延短;后一种会减少写的时延,但是读的时延长。我们可以根据具体的业务要求进行选择。

  1. Delivery Semantics:delivery semantics可以分为三种:at most once, at least once, 和exactly once。下面分别讨论。at most once:即更新最多只更新一次,也可能是零次。对应的更新逻辑为先commit update,然后进行update。at least once:即更新至少一次,也可能多次。对应的更新逻辑为先进行update,然后commit update。exactly once:即保证更新至少一次,且只为一次。最普遍的操作方式是at least once加上下游consume的操作需要幂等(idempotent)。在实际业务中使用最多的是at least once。

  2. partition 扩容(reparition):考虑到partition内部数据巨大,通常的做法是保持旧有数据不动,仅仅将新的数据映射到增加的partition上。这样带来的一个副作用是之前同一个partition内部的消息顺序会受到影响。这也是目前kafka的做法。如果我们为了减少增加partition带来的消息顺序的影响,可以考虑使用consistent harsh,这样会最小化该影响。另外一种比较高级的做法是采用virtual partition,然后维护virtual partition和physical partition之间的映射关系,这样增加physical partition不会影响在virtual partition里的消息顺序。