5

胖客户端,瘦服务器?

 3 years ago
source link: https://zhuanlan.zhihu.com/p/355365764
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

胖客户端,瘦服务器?

软件开发话题下的优秀答主

自从十年前 Web 2.0 以及移动互联网大行其道后,客户端应用变得越来越「苗条」,业务逻辑能往后端放就往后端放,更加偏重交互相关的逻辑;而服务器端则变得越来越复杂,维护越来越多的状态,模板,数据,以及业务逻辑。服务器端从早期的一体化架构(monolithic arch)到现在大行其道的微服务架构(microservice arch),再到方兴未艾的无服务架构(serverless arch),复杂性呈指数级上升。复杂带来的最主要的问题是难以理解 —— 一条 API 后面驱动的动辄十几个甚至几十个服务,使得并不是每一个工程师都能在一块白板上画清楚数据如何在系统中流动。

其实,谁也不喜欢这样的复杂性。但所谓的 "internet-scale" 的应用不断提升的对扩容的需求,对并发处理的需求,使得我们不得不将后端架构不断解构,以方便扩容 —— 起先是数据库和应用服务器的分离,然后是应用服务器自身组成部分的分离,随后是基础设施的分离。一次又一次的分离,导致越来越多的代码用于通讯,容错,监控,日志,以及部署。一切显得那么自然,就像托勒密的地心说:为了达到让观测数据能够符合地心说的目的,当时的天文学家们首先想到的是,如何增加更多的「本轮」来让观测数据满足理论本身。然而,(哥白尼之前)没有一个人问:如果地球不是宇宙的中心呢?

v2-f533cca82c1022b5b63e0f05ec710e6c_720w.jpg

幸运的是,我们有哥白尼,伽利略,他们振臂一呼,犹如一道闪电劈入黑得无边的天际,给死气沉沉的科学界带来希望的曙光。

爱因斯坦说:如果我只有一个小时解决某个生死攸关的问题,我会花 55 分钟去决定要问什么问题。

那么,为何我们不问问自己:为什么我们要把服务端的逻辑做得这么复杂?

为什么呢?

因为这样能在高并发下提供更好的服务体验

为什么我们需要这么高的服务并发能力?

因为客户端的大部分行为和动作都需要和服务端交互

为什么客户端需要这么频繁地和服务端交互?

抛开 analytics 不说,主要原因是客户端不持有状态或者只持有服务端状态的副本或缓存

为什么客户端不能持有它需要的全部状态?

呃,因为,web 无法稳定长期持有本地状态?

那为什么 native 端(iOS / android / windows / mac)不能持有它需要的全部状态?

呃。。呃。。因为我们不想同样的业务逻辑在每个端都实现一遍

那为什么有的 APP 只有桌面版或者某一个 mobile 版本,还不持有它需要的全部状态?

呃。。呃。。呃。。因为万一哪一天要出个 web 版呢。其实是因为我们习惯了服务端保存所有状态,习惯了 REST 或者其他类似的 API 调用方式

所以,当我们刨根问底之后,我们发现,这个问题的根源是两个:1) web 并没有一个对应用来说容量足够可以长期保存状态的本地存储 2) 我们已经习惯了 B/S 模型,脱离了这个模型,很多人可能都不知道该如何做 app 了。

第一个问题的主要原因是 web 的 local storage 有 5MB 的限制,所以只适合做缓存。这个暂时无解。

第二个问题是由第一个问题衍生而来的,web 从诞生之初就只适合做非常简单的客户端应用,php/rails/django + RDBMS 燃起的 web 2.0 的星星之火将 web 的能力提升了一个量级,后来者不断仿效在此基础上优化迭代,造就了一个无比繁荣的 web 生态,以至于后来投身这个生态的开发者们,就像桃花源记里的描述那样:不知有汉,何论魏晋。

瘦服务端的思路

我们看看,如果不用 B/S 模型,把状态保存在客户端,这么做 app 的话,可以怎么弄?

首先,状态在客户端,并不是不需要服务器端。对于一个互联网用户来说,我们还需要服务器端做数据的 synchronize(并发语义下的同步,而非一般意义的同步),以及数据的单一来源(Single Source of Truth)。如果大量的客户端向服务器提交数据,服务器需要将其排序储存及分发,让所有的客户端都能拿到一致的数据(至少 eventually consistent)。

所以,服务器能够接收客户端提交的数据,排序(保持一致性),存储,并且在客户端需要某些数据的时候能够快速回溯到需要的数据,并分发给客户端。

客户端可以通过它请求到的服务器端的数据,构建自己本地的状态。以后不断拉取(pull)或者通过服务端的推送(push),得到最新的数据,把自己本地的状态更新成最新的状态。客户端本地的状态完全在本地,不需要从服务端获取。

听着似曾相识?

对,听着有点像区块链,又有点像 Git。

是的。原本这篇文章我草拟的标题就是:用区块链或 GIT 的方式构建应用程序?后来觉得太长,就弃用了。

我们看区块链:

  1. 客户端拥有全部状态。你可以不联网,依旧可以访问到所有本地的数据,联网后获取最新的 blocks,本地一个 block 一个 block 地运算,把状态更新到最新。
  2. 客户端发送「原子」数据 transaction,「服务端」进行数据的排序。因为区块链一般具备不同程度的去中心化属性,我这里的「服务端」特指参与排序(也就是 consensus 的主要工作)的节点,这个服务端,对于 BTC 就意味着全网的节点,而对于 EOS 就意味着区区 21 个验证人节点(或者说超级节点)。
  3. 排序好的数据会打包成一个个 block,成为数据分发和获取的基石。这样每个客户端同步数据就变得简单:我现在块高在 9527,你是 9627,所以我需要 9528 - 9627 之间的所有数据。在此期间,新收到的数据我暂时存着,直到我的状态达到最新,再处理。

我们再看 Git:

  1. 客户端拥有全部状态。你可以不联网,依旧可以访问到所有本地的数据并该干嘛干嘛,联网后可以手动获取最新的 commits,本地一个 commit 一个 commit 去更新工作目录(working directory),把本地工作目录更新到最新。
  2. 客户端发送 commit 以及这个 commit 里更改过的 blob,「服务端」不自动进行数据的排序,完全依赖于客户端提交的 commit 基于最新的 commit。如果两个 commit 有冲突,客户端需要手动解决(区块链通过预置的 consensus 算法解决)。这样提交到服务端的 commits 自然是排好序的。
  3. 对于 Git 来说,commit 以及和 commit 有关的 blobs 是数据分发和获取的基石。客户端同步数据也很简单:我的 head 是 0b39,你的 head 是 a758,我们中间有 100 个 commits,于是我需要所有这些 commits 以及它们包含的 blobs。如果这个过程中或者过程前本地产生了新的 commit,那需要经历一次 merge。

我们从两者的共通点看看可以学到什么有用的东西:

  1. 客户端产生事件(event),事件经过排序(无论手工还是自动)后被打包储存成方便读取的形式(区块链一般使用 linklist,而 Git 使用 DAG),供其他人访问。
  2. 事件是原子的,不可修改的。客户端按顺序拿到所有有关的事件后,就可以构建一致的状态了。
  3. 客户端告诉服务器自己获取到的最新的事件是什么,就可以很容易从服务器拉取从这点之后的所有事件了。除了第一次拉取数据外,以后的更新,都是增量更新。
  4. 如果客户端的状态被破坏了,只需要重新 replay 一遍所有的事件,就能复原整个状态。
  5. 客户端如果卸载了,全部本地数据丢失,也可以重装后全量 clone 一下服务端的数据,就能恢复整个状态。

循着这个思路,我们可以构造出客户端和瘦服务器间的所有交互:

  • Clone:拉取全量数据
  • Push:提交本地生成的新的事件
  • Pull:拉取从某个事件之后的所有事件

在这个基础上,还可以加上 Subscribe/Unsubscribe 来监听某些感兴趣的服务端得到的新的事件。

这和我们熟悉的 Git 操作非常类似。

当然,这些操作披着 Git 的外衣,但处理方式更贴近区块链。因为我们希望由服务端的共识算法来决定如何排序,而不是靠客户端之间手工同步,这种方式,对用户很不友好。

但是,我们又很不喜欢区块链低效的,全局排序的处理方式(全部数据全局共识)。事件之间是有内在联系的,不相干的事件放在一起排序是没有意义的(这就是我认为 BTC 的全网共识的思路是 ok 的,而 ETH 是别扭的,不 ok 的,每个 contract 的 tx 应该只跟这个 contract 相关的 tx 进行排序做共识)。所以,从 Git 那里,我们可以偷师 repo 的概念:事件的排序发生在 repo 内部;不同 repo 间的数据,可以不需要排序。

所以,我们用 repo 作为事件的防火墙。一个 repo 类似一条区块链。

兵棋推演:瘦服务端的实现

好,我们有了基本的架构思路后,接下来就是怎么实现它。

一般到这个时候,就是数据库的选型了。

等等。为什么我们需要数据库?

呃。。呃。。因为,好像,我们现在做软件设计不都是从 database schema 开始么?

似乎也对。那么稳妥起见,我们找个 RDBMS,比如 Postgres 开始吧。

我们要存储的主要对象是事件。按照我们刚才的描述,一个事件大概长这个样子:

repo_id:事件属于哪个 repo(这个 repo_id,类似于 partition key)

  • timestamp:服务器端插入事件的 UTC 时间戳
  • signature:事件是由谁签署的(参考区块链),从这个信息我们可以确认事件的作者以及事件的有效性
  • event data:protobuf 或者其他方式序列化的事件数据本身。这里用 protobuf 或者类似的解决方案,是为了数据格式升级后,应用程序依然能够兼容历史数据。注意,这里的 event 不要跟 analytics event 混淆哦。

我们用 Postgres 把这样的信息存储在表 events 里,然后看几个操作如何处理:

  • Clone: select * from events where repo_id = <repo_id>
  • Push:insert into events ...
  • Pull:select * from events where repo_id = <repo_id> and timestamp > <ts>

似乎,还说得过去?

但稍微有经验的同学会感觉这样使用 RDBMS 有些别扭:RDBMS 的优点(关系处理,事务提交等)根本用不上,而不擅长的(易于扩容)却被无限放大。想象一下如果客户端每秒产生 5000 个事件,一个月会有 13.4G 事件产生,也就意味着 134 亿增量数据。一张表里月度 134 亿增量数据,这对 RDBMS 的存储和查询都会造成很大的困扰。即便我们通过分表分库解决了,账单数目(compute + storage)也很可观。

RDBMS 似乎不合适。那么使用 KV store 如何?比如 DynamoDB?

scaling 的问题可以解决,Pull 因为涉及到对 timestamp 的查询,所以需要 secondary index,略麻烦,但不是问题。最大的问题还是账单。5000 事件 / 秒,往 DynamoDB 里写,画面太美。每千次 WCU 是 $0.65,粗略算一个月下来,光写入的账单就 $174万。就算我们能把写入聚合,100 个一组写入,也是小两万的写入支出,想想都脊背发凉。而且平均 5000 事件 / 秒,意味着我们可能需要 provision 更高的 WCU 以应对峰值,所以这个还是保守估计。

用数据库做 event store 这条路,我们知道,走到一定程度,必然无法继续走下去。To be or not to be, this is a question.

回到这个小节最初的问题:为什么我们需要数据库?

数据库是用来存储和查询状态,以及处理事务的,但在这个场景下,数据库只是被用来做全局存储以及(间接地)解决排序问题 —— 当多个 insert 竞争写入时,谁先写谁后写;其它的问题,其实用数据库解决都是高射炮打蚊子 — 大材小用。

那么,用日志文件呢?因为这里描述的事件有序且不可删改,非常适合 append only log。我们知道,磁盘(或者 SSD)顺序写入的效率非常高(还嫌不够快可以 pre-allocate + mmap + 定期 fsync),如果我们为每个 repo 建立一个日志文件(逻辑上是一个,物理上可以是一组),然后围绕着日志文件再建立一个 index:

我们看服务端提供的几个操作该如何完成:

  • Clone:直接把 repo_id 对应的日志文件按顺序 send_file 回去(zero-copy 拿去不谢),O(k),k 是这个 repo 所有日志文件的数量
  • Push:往日志尾部添加,O(1)
  • Pull:从 index 里找到第 M 个事件(客户端最后一个事件),将 (M+1, N) 的所有内容 send_file 回去。这里从 index 里找是 O(1) 的操作。

所有操作都非常简单高效。

当然,这种方法忽略了事件排序这个最重要的问题。我们必须解决它。前面讲过,排序的目的是为了所有客户端在获取某个 repo 下的事件时,拿到的都是一致的顺序。使用数据库,我们可以达到这个目的,但显然,数据库不是用来干这个的。

还有什么选择?

当然是消息队列或者任何 event streaming platform,比如 Kafka,NATS stream,以及开箱即用的 Kinesis stream。

以 Kinesis 为例。数据从用户侧发给 event service server,然后存入 kinesis。kinesis 会根据 shards 对事件排序。shard id 可以根据 repo_id 映射而来。所以 Push 很简单。

Kinesis 有 data retention 的限制,数据存在其间的时间越长,花费就越昂贵。这时,我们可以做一个 snapshot service 定期(比如 1 小时)把 stream 中的所有未读事件读出来,按 repo_id 分类,每个 repo 本地写成一个文件,sync 到 S3。S3 可以保留最近一天按小时的日志,最近一月按天的日志,最近一年按月的日志,年度的日志,以及这个 repo 的索引。

而每台 event service server,则一直从 Kinesis 拉数据,有多少拉多少,按 repo 写入本地的循环日志中。因为大部分数据都已经存储到 S3 上了,服务器本地只需要保存两个小时之内的最新数据即可。但服务器需要保存全量的 repo 索引,repo 索引文件很小,按一个 index entry 32bit 文件序号,和 32 bit offset 算,一个拥有 1M 事件的 repo 索引,也就 8M。要知道,github 上参与人数最多,更新最频繁的 repo — torvalds/linux,十六年(虽然 linux 时间更久,但 git 2005年才诞生)的更新也才累计了接近 1M commits(截止到本文撰写时,是 995,819),所以服务器本地保存全量索引问题不大(如果还担心索引太大,可以用两级索引)。

这里,nginx 在 dispatch 时,还可以进一步优化:根据 repo_id 哈希把事件按 repo_id 分配到不同的 event server,这样,单台服务器只需要处理和它相关的 repo。

整个 flow 如下图所示:

我们看服务端几个操作如何完成:

  • Clone:服务端查询本地索引,返回这个 repo 下的 S3 snapshot pre-signed link + 当前服务器上的 S3 上没有的热数据。还是 O(k),k 是 snapshot 文件的数量
  • Push:服务端将其 put_record 或者 put_records。还是 O(1)。
  • Pull:服务端通过本地索引找到第一个事件的位置,然后处理:
  1. 如果索引指向 S3 snapshot 里的某个文件,把该文件之后的所有文件类似于 Clone 操作返回。这里服务端可能会返回多余客户端需要的事件(假设客户端当前事件的下一个指向 S3 某个文件的中间,这时整个文件会被返回),客户端需要自行略过已经处理过的事件。
  2. 如果索引找到的位置本地都有,则返回本地数据

服务端大致成本计算

还是假设客户端平均每秒发送 5000 个事件(这个量级是非常非常可观的),平均每个事件 256 字节,服务端对事件做了聚合及压缩,100 个事件聚合并压缩放在一个 put_record 里,每个 record 压缩后 18k(70% 压缩率),这样,每秒 50 个 record,每个 record 18k,所以 0.9MB/s,需要 1 个 shard。

  • Shard 费用:1 个 shard 一天 0.36,一个月 $11.16。
  • Put 费用:18k 没超过 25k 的 Put Payload Unit,所以 50 PPU/s,一个月 0.134G,按 1G $14 价格,大概 $1.87。

我们假设还用不到 extended fanout。

再看 S3 的费用:

  • 按上面的计算,一个月增量的数据是 2.4T。存储费用是 $23/T,所以 $55。
  • 我们每小时写一次,假设平均每次写 5000 个 repo,那么一个月 3.7M put,按 1M put $5,是 $18.5。
  • 假设每小时 1000 次 clone,每次 clone 读 5 个文件,每小时有 10000 次 Pull,每次读两个文件,那么每月是 18.6M 次 get。1M get $0.4,所以是 $7.4。

EC2 拍脑门估一下,按 12 台算,两台 nginx,十台 event service(其中两台同时兼做 snapshot writer),都是 c6gd.large(使用本地 SSD 避免 EBS 性能损失和额外花费),每小时 0.0768,一个月下来 $685.67。

带宽成本按照刚才 S3 get 的估算,再假设平均文件长度 100k,那么每个月 S3 大概 1.86T,EC2 会少不少,加起来按 2.5T 算。每 T 价格 90,一个月 $225。

所以全部成本是:$1005。看上去还不赖。比数据库的解决方案省钱多了(注意这里的大头还是在 EC2 + 带宽,除去这些公共的,其它方案也需要的开销,event store 本身 只需要花费 $95 一个月)。

胖客户端的实现思路

假设有了这样一个 event store,客户端可以进行 Push / Pull / Clone 这些基本的操作,我们要做一个正儿八经的 app,该怎么搞?

我们以一个简化版的,先不考虑权限控制和分享的 Notion 为例。对于这样一个编辑工具,我们可以定义这样的事件:

  • CreateRepo(repo_id, template_id):从一个可选的 template 里创建 repo。在 Notion 里,一个 workspace 就是一个 repo。
  • CreateContent(repo_id, content_id, parent_content_id, previous_content_id):在目录树中创建文章。
  • CreateBlock(repo_id, content_id, block_id, position):在文章中的某个位置创建 Block。
  • AppendComment(repo_id, content_id, block_id, comment):在文章级别(block_id 为空)或者 block 级别追加评论。
  • UpdateBlock(repo_id, content_id, block_id, block_data):更新 block
  • MoveBlock(repo_id, content_id, block_id, position):在文章中移动 block(先不考虑 multi column)
  • MoveContent(repo_id, content_id, parent_content_id, previous_content_id):在 repo 中移动文章
  • UpdateComment(repo_id, content_id, block_id, pos, comment):更新评论
  • DeleteContent(repo_id, content_id):删除文章
  • DeleteBlock(repo_id, content_id, block_id):删除 block
  • DeleteComment(repo_id, content_id, block_id, pos):删除评论

对于每种事件,我们可以定义一个 trait,包含 applyundo 两个方法:

  • apply(self, &state, &stack):将事件更新到当前 repo 的客户端状态中。我们可以做一个优化:apply 新的事件时,我们偷窥一下栈顶,如果两个事件互为 undo 的话,比如 CreateBlock 后下一个事件是 DeleteBlock,我们可以 apply DeleteBlock 后不压栈,反而把 CreateBlock pop 出来,就像 undo 一样。
  • undo(self, &state):取消事件的处理,将当前 repo 回滚到不包含该事件的客户端状态。

根据这些事件,我们可以很容易产生出这样的客户端状态:

  • repo 的目录树
  • 文章内容(由文章里所有 block 按顺序组成)

客户端对于一个 repo 维护一个事件堆栈(同时也是队列),用户的操作在这个堆栈尾部 push / pop,而每隔一段时间(比如 2s),就会触发客户端往服务端提交当前堆栈上的事件。如果成功,就挪动 checkpoint,否则重试,直到提交成功。

这里会存在在提交之前,服务端从其它客户端那里更新了新的事件,导致本地还未提交的事件和服务器端有冲突,这里面,大部分冲突都可以用算法解决,少部分我们需要用户手工合并。这种情况发生在多人同时编辑一个文章时。这里,我们可以在产品的角度去减少无法合并的冲突发生的可能,比如,block 的编辑处在独占模式:一个 block 在被某个用户编辑时,其它用户就无法修改。

注意程序君并没有真正实现上述思路,所有的一切不过停留在我的脑海中。也许,某天我兴致来了,会尝试着做个 demo。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK