本文是《搞定系统设计:面试敲开大厂的门》第1-9章的读书笔记摘抄,第10-17章的笔记请参考Part 2。
从0到100万用户的扩展
不断优化、持续改进。
单服务器
Web和移动应用。
数据库
NoSQL的四类:键值存储、图存储、列存储和文档存储。
满足如下条件时,可考虑NoSQL:
- 应用对延时的要求非常高;
- 应用中的数据是非结构化的,或没有任何关系型数据;
- 只需要序列化和反序列化数据;
- 需存储海量数据。
纵向扩展和横向扩展
纵向扩展:也叫向上扩展,提升服务器的能力。重大局限:
- 有硬性限制;
- 没有故障转移和冗余。一台服务器宕机,网站/应用也会随着一起完全不可用。
横向扩展:也叫向外扩展,为资源池添加更多服务器。
负载均衡器
为提高安全性,服务器之间的通信使用私有IP地址。私有IP地址只可以被同一个网络中的服务器访问,在公网中是无法访问的。
增加负载均衡器和一台Web服务器后,成功解决网络层的故障转移问题,提升网络层的可用性。
数据库复制
数据库复制的优点:
- 性能更好:在主从模式下,所有的写操作和更新操作都发生在主节点(主库)上,而读操作被分配到各个从节点(从库),因此系统能并行处理更多的查询,性能得到提升;
- 可靠性高:如果有一台数据库服务器因自然灾害而损毁,数据依然被完好保存,不需担心数据丢失;
- 可用性高:不同物理位置的从库都有复制数据,即使一台数据库服务器宕机,网站依然可运行;
宕机:
- 从库:如果只有一个从库,而它宕机了,则系统暂时会将读操作路由至主库。一旦发现有从库宕机,就会有一个新的从库来替代它。要是有多个从库可用,读操作会被重定向到其他正常工作的从库上;同样,也会有一个新的数据库服务器来替代宕机的那个
- 主库:如果主库宕机,会有一个从库被推选为新的主库。所有的数据库操作会暂时在新的主库上执行。另一个从库会替代原来的从库并立即开始复制数据。在生产环境中,因为从库的数据不一定是最新的,所以推选一个新的主库会更麻烦。缺失的数据需要通过运行数据恢复脚本来补全。尽管还有别的数据复制方式可以解决数据缺失问题,比如多主复制或循环复制,
缓存
设置独立缓存层的好处有:提高系统性能,减轻数据库的工作负载以及能够单独扩展缓存层。
注意事项:
- 决定什么时候应使用缓存。如果对数据的读操作很频繁,而修改却不频繁,则可考虑使用缓存。因为被缓存的数据是存储在易变的内存中的,所以缓存服务器不是持久化数据的理想位置。比如,如果缓存服务器重启,其中的所有数据就会丢失。因此,重要的数据应该保存在持久性的数据存储中;
- 过期策略。执行过期策略是好的做法。一旦缓存中的数据过期,就应该将其从缓存中清除。如果不设置过期策略,缓存中的数据会一直被保存在内存中。通常建议不要把过期时间设得太短,因为这样会导致系统不得不经常从数据库重新加载数据;当然,也不要设得太长,这样会导致数据过时;
- 一致性:这关系到数据存储和缓存的同步。当对数据的修改在数据存储和缓存中不是通过同一个事务来操作的时候,就会发生不一致。当跨越多个地区进行扩展时,保持数据存储和缓存之间的一致性是很有挑战性的;
- 减轻出错的影响:单缓存服务器是系统中的一个潜在单点故障(Single Point Of Failure,SPOF)。单点故障是指系统中的某一部分,如果出现故障,整个系统就不能工作。推荐的做法是在不同的数据中心部署多个缓存服务器以避免单点故障。另一个推荐的做法是为缓存超量提供一定比例的内存,这样可以在内存使用量上升时提供一定的缓冲;
- 驱逐策略:一旦缓存已满,任何对缓存添加条目的请求都有可能导致已有条目被删除,这叫作缓存驱逐。LRU(Least-Recently-Used,最近最少使用)是最流行的缓存驱逐策略。也可以采用其他缓存驱逐策略,比如LFU(Least Frequently Used,最不经常使用)、FIFO,以满足不同的使用场景。
内容分发网络
动态内容缓存,可基于请求路径、查询字符串、cookie和请求头来缓存HTML页面。
使用CDN时的注意事项:
- 花销:CDN是由第三方供应商来运营的,对数据在CDN中的进出都会收费。缓存不经常使用的内容,并不能给性能带来显著的好处,应该考虑把这些内容从CDN中移出。
- 设置合理的缓存过期时间:对于时间敏感的内容,设置缓存过期时间是很重要的。这个时间不应该过长或过短。如果过长,内容会不够新。如果过短,可能导致频繁地将内容从数据源服务器重新加载至CDN。
- CDN回退:要好好考虑你的网站或应用如何应对CDN故障。如果CDN出现故障暂时无法提供服务,客户端应该有能力发现这个问题,并直接向数据源服务器请求资源。
- 作废文件:以下操作均可以在文件过期之前将其从CDN中移除:
- 调用CDN服务商提供的API来作废CDN对象;
- 通过对象版本化来提供一个不同版本的对象。可以在URL中添加一个参数,比如版本号,来给一个对象添加版本。比如,在查询字符串中可以加入版本号2(image.png?v=2)。
无状态网络层
有状态架构,问题是:如何将来自同一客户端的所有请求都发给同一个服务器。负载均衡器都提供的黏性会话[插图]可以解决这个问题,但是会增加成本。这种方法使得添加或者移除服务器变得更加困难,同时也使得应对服务器故障变得更具挑战性。
无状态架构:把会话数据从网络层中移出,放到持久化存储中保存。共享数据存储,选择NoSQL,好处在于易扩展。自动扩展:基于网络流量自动地增加或减少Web服务器。
数据中心
geoDNS,基于地理位置的域名服务,一种基于用户的地理位置将域名解析为不同IP地址的DNS服务。
设置多数据中心,须先解决如下技术难题:
- 流量重定向。要有能把流量引导到正确数据中心的有效工具。geoDNS可以基于用户的地理位置把流量引导到最近的数据中心;
- 数据同步。不同地区的用户可以使用不同的本地数据库或者缓存。在故障转移的场景中,流量可能被转到一个数据不可用的数据中心。常用的一个策略是在多个数据中心复制数据;
- 测试和部署:设置多数据中心后,在不同的地点测试你的网站/应用是很重要的。而自动部署工具则对于确保所有数据中心的服务一致性至关重要。
消息队列
支持异步通信、用作缓冲区、解耦。
记录日志、收集指标与自动化
几类指标:
- 主机级别指标:CPU、内存、磁盘I/O等;
- 聚合级别指标:比如整个数据库层的性能,整个缓存层的性能等;
- 关键业务指标:每日活跃用户数、留存率、收益等。
数据库扩展
纵向扩展:向上扩展。亚马逊的RDS可提供24TB内存的数据库服务器。重大缺点:
- 硬件能力有上限;
- 更大的单点故障风险;
- 总成本很高。
纵向扩展:分片,Shard。实施分片策略时,要考虑的最重要的问题是选择什么分片键(Sharding Key,也叫分区键,Partition Key)。
分片引入的挑战:
- 重分片数据:出现如下情况时,需要对数据重新分片。第一种是因为数据快速增长,单个Shard无法存储更多的数据。第二种是因为数据的分布不均匀,有些Shard的空间可能比其他的更快耗尽。当Shard被耗尽时,就需要更新用于分片的哈希函数,然后把数据移到别的地方去。我们会在第5章介绍一致性哈希算法,它是解决这个问题的常用技术。
- 热点问题:过多访问一个特定的Shard可能造成服务器过载。为了解决这个问题,我们可能需要为每个名人都分配一个Shard,而且每个Shard可能还需要进一步分区;
- 连接和去规范化(de-normalization):一旦数据库通过分片被划分到多个服务器上,就很难跨数据库分片执行连接(join)操作了。解决这个问题的常用方法就是对数据库去规范化,把数据冗余存储到多张表中,以便查询可以在一张表中执行。
总结
技术要点:
- 让网络层无状态;
- 每一层都要有冗余;
- 尽量多缓存数据;
- 支持多个数据中心;
- 用CDN来承载静态资源;
- 通过分片来扩展数据层;
- 把不同架构层分成不同的服务;
- 监控你的系统并使用自动化工具。
封底估算
封底估算:Back-of-the-Envelope Estimation,粗略计算,介于猜测与精确计算之间,在可用的废纸(如信封背面)上即可完成。
2的幂
1字节(byte)是8比特(bit),一个ASCII字符占用1字节内存(8比特)。
操作耗时
可供参考的操作耗时
操作名称 | 耗时 |
---|---|
查询L1缓存 | 0.5ns |
分支预测错误 | 5ns |
查询L2缓存 | 7ns |
互斥锁定/解锁 | 100ns |
查询内存 | 100ns |
用Zippy压缩1KB数据 | 10000ns=10μs |
通过带宽为1Gb/s的网络发送2KB数据 | 20000ns=20μs |
从内存中顺序读取1MB数据 | 250,000ns=250μs |
数据在同一个数据中心往返一次 | 500,000ns=500μs |
在硬盘中查找数据 | 10,000,000ns=10ms |
从网络中顺序读取1MB数据 | 10,000,000ns=10ms |
从硬盘中顺序读取1MB数据 | 30,000,000ns=30ms |
将数据包从加利福尼亚发送至荷兰,再从荷兰返回加利福尼亚 | 150,000,000ns=150ms |
分析表及图,如下结论:
- 内存的速度快,而硬盘的速度慢;
- 如果有可能,尽量避免在硬盘中查找数据;
- 简单的压缩算法速度快;
- 尽可能将数据压缩之后再通过因特网传输;
- 数据中心通常位于不同的地区,在它们之间传输数据需要时间。
可用性
高可用性是指一个系统长时间持续运转的能力。如下表
可用性(百分比) | 每天不可用时长 | 每年不可用时长 |
---|---|---|
99% | 14.4分钟 | 3.65天 |
99.9% | 1.44分钟 | 8.77小时 |
99.99% | 8.64秒 | 52.6分钟 |
99.999% | 864毫秒 | 5.26分钟 |
99.9999% | 86.4毫秒 | 31.56秒 |
案例
估算推特的QPS和存储需求。峰值QPS=QPS的2倍。
小技巧
几个小技巧:
- 凑整和近似;
- 写下假设;
- 标明单位;
- 练习,熟能生巧。
系统设计面试的框架
考察能力:协作、压力下工作、建设性解决模糊问题、提问等。
警惕:过度设计、忽视权衡、想法狭隘、固执等。
四个步骤
有迹可循。
理解问题并确定设计的边界
别急着给出解决方案,慢一点,深入思考并提几个问题来厘清需求和假设。
工程师最重要的技能之一就是问正确的问题,做合适的假设,并收集构建系统需要的所有信息。
提议高层级的设计并获得认同
步骤:
- 制定一个初始蓝图
- 用关键组件画出框图
- 做封底估算
设计继续深入
目标:
- 就系统的整体目标和功能范围,与面试官达成一致;
- 勾画出系统整体设计的高层级蓝图;
- 从面试官那里得到关于系统高层级设计的反馈;
- 基于面试官的反馈,大概知道自己需要在哪些地方继续深入研究。
总结
方向:
- 识别出系统的瓶颈并讨论改进方案;
- 简要复述。
正确的操作:
- 总是向面试官寻求明确的解释,不要认为你的假设是对的;
- 理解问题,及要求;
- 没有正确的答案或最佳答案。为了解决创业公司的问题而设计的方案与为了解决拥有数百万用户的成熟公司的问题而设计的方案是不相同的。要确保你理解了需求;
- 让面试官知道你在想什么,与面试官持续沟通;
- 如果有可能,请提出多个方案;
- 一旦你和面试官就设计蓝图达成一致,接下来就要深入讨论每个组件的细节。先设计最重要的组件;
- 试探面试官的想法,好的面试官会和你一起解决问题;
- 永不放弃。
禁忌:
- 对于常见的面试问题没有做好准备;
- 在没有弄清需求和假设之前就给出解决方案;
- 在面试一开始就讨论关于某一组件的大量细节。请先给出高层级设计后再深入讨论细节;
- 思路卡住时干着急。如果你一时找不到解题的突破口,去找面试官要点提示,不要犹豫;
- 不沟通。再说一次,一定要沟通。别在那里一个人默默思考;
- 认为给出设计方案后面试就结束。面试官说结束才是真的结束。要尽早且尽量频繁地征求面试官的反馈。
时间分配
如果面试是三刻钟:
- 理解问题并确定设计的边界,3~10分钟,;
- 提议高层级的设计并获得认同,10~15分钟,;
- 设计继续深入,10~25分钟;
- 总结,3~5分钟。
设计限流器
使用限流器的好处:
- 预防由拒绝服务攻击(Denial of Service,DoS)引起的资源耗尽问题。大型科技公司发布的所有API几乎都强制执行某种形式的限流操作。限流器通过有意或无意地拦截超额的请求来预防DoS攻击;
- 降低成本。限制过量的请求意味着需要的服务器更少,把更多资源分配给优先级更高的API。尤其是使用第三方付费API的场景。比如,对于外部API,如检查信用值、请求付款、获取健康记录等,你需要按照请求次数付费;
- 预防服务器过载:过滤机器人或用户不当操作所造成的过量请求。
理解问题并确定设计的边界
需求:
- 准确限制过量的请求;
- 低延时:不能拖慢HTTP响应时间;
- 尽量占用较少内存;
- 分布式:可在多个服务器或进程之间共享;
- 异常处理:当用户的请求被拦截时,给用户展示明确的异常信息;
- 高容错性:限流器出现任何问题,不能影响整个系统。
提议高层级的设计并获得认同
采用基本的客户—服务器(C/S)通信模式。
在哪里实现限流器
客户端请求容易被恶意伪造,因此在服务端或网关层实现限流。指导原则:
- 评估现有技术栈;
- 限流算法;
- 能否把限流器加在API网关上;
- 商业版API网关。
限流算法
代币桶算法
Token Bucket,令牌桶。
漏桶算法
Leaking Bucket
固定窗口计数器算法
Fixed Window Counter,
滑动窗口日志算法
Sliding Window Log
滑动窗口计数器算法
Sliding Window Counter,
高层级架构
使用Redis。
设计继续深入
限流规则
Lyft(美国仅次于优步的第二大的叫车公司)开源限流组件。限流规则示例:
domain: messaging
descriptors:
- key: message_type
Value: marketing
rate_limit:
unit: day
requests_per_unit: 5
超过流量的限制
限流器返回下面的HTTP头给客户端:
X-Ratelimit-Remaining
:在当前时间窗口内剩余的允许通过的请求数量;X-Ratelimit-Limit
:客户端在每个时间窗口内可以发送多少个请求;X-Ratelimit-Retry-After
:被限流后需等待多少秒才能继续发送请求;
当用户请求过多时,限流器将向客户端返回HTTP响应码429(请求太多)和X-Ratelimit-Retry-After响应头。
详细设计
设计架构图
分布式系统中的限流器
性能优化
两方面:
- 多数据中心
- 同步数据使用最终一致性,而不是强一致性
监控
检查:
- 限流算法生效
- 限流规则生效
总结
话题:
- 硬限流与软限流:前者指请求数量不能超过阈值,后者指请求数量可在短时间内超过阈值;
- 在不同层级做限流:OSI模型各个层如何限流,如Iptables在第3层根据IP地址限流;
- 防止被限流:设计客户端的最佳实践:
- 客户端缓存,避免频繁地调用API;
- 理解限流,不要在短时间内发送太多请求;
- 添加代码以捕获异常或错误,使客户端优雅地异常恢复;
- 在重试逻辑中添加足够的退避时间。
设计一致性哈希系统
横向扩展,使用一致性哈希。
重新哈希的问题
问题:缓存未命中。
一致性哈希
维基百科:
平均只需要重新映射 k / n k/n k/n个键,k是键的数量,n是槽(Slot)的数量。
哈希空间:一个数组;
哈希环:数组首尾相连;
哈希服务器:根据服务器的IP或名字将其映射到哈希环上;
哈希键:key0,...,keyn
;
查找服务器:沿着哈希服务器顺时针查找;
添加服务器:只有少部分键需被重新映射到新添加的服务器上;
移除服务器:同上;
基本步骤:
- 使用均匀分布的哈希函数将服务器和键映射到哈希环上;
- 找出某个键被映射到哪个服务器上,从这个键的位置开始顺时针查找,直到找到哈希环上的第一个服务器。
两个问题:
- 很难保证哈希环上所有服务器的分区大小相同,分区是相邻服务器之间的哈希空间;
- 键在哈希环上非均匀分布;
总之就是数据分布不均衡,数据倾斜。
解决方法:引入虚拟节点或副本。
虚拟节点:实际节点在哈希环上的逻辑划分或映射。通过虚拟节点,每个服务器都对应多个分区。虚拟节点越多,键的分布就会越均匀,当然也会消耗更多的存储空间。
总结
一致性哈希的好处:
- 添加或者移除服务器的时候,需要重新分配的键最少;
- 更容易横向扩展,因为数据分布得更均匀;
- 减轻热点键问题。过多访问一个特定分区可能会导致服务器过载。
设计键值存储系统
键值存储,键值数据库。
理解问题并确定设计的边界
特点:
- 每个键值对都不大,小于10KB;
- 可存储大数据;
- 高可用性;
- 高可扩展性:以支持大数据集;
- 自动伸缩:可基于流量自动添加/移除服务器;
- 可调节的一致性;
- 低延时。
单服务器的键值存储
单服务器,优化方向:
- 压缩数据;
- 内存+硬盘。
分布式键值存储
分布式哈希表。
CAP理论
CAP理论:一个分布式系统最多只能同时满足下面三个特性中的两个:
- 一致性(Consistency):所有的客户端在相同的时间点看到的是同样的数据;
- 可用性(Availability):即使有节点发生故障,任意客户端发出的请求都能被响应;
- 分区容错性(Partition Tolerance):尽管网络被分区,系统依然可运行。
网络故障无法避免,分布式系统必须容忍网络分区。在现实世界中CA系统不可能存在。
数据分区
数据分区的两个挑战:
- 将数据均匀地分布在多个服务器上;
- 添加或移除节点时,尽量减少数据迁移。
解决方法:一致性哈希。好处:
- 自动伸缩:可基于负载自动添加和移除服务器;
- 异质性:服务器的虚拟节点数量可与服务器的性能成比例。
数据复制
哈希环顺时针遍历选择服务器时,只选择不重复的服务器。
多数据中心。
一致性
版本控制
解决不一致问题的方法:版本控制和向量时钟。
版本控制:每次修改数据都生成一个新的不可变的数据版本。
向量时钟:与数据项相关联的[服务器,版本]对。它可以用于检查一个版本是先于还是后于其他版本,或者是否与其他版本有冲突。
向量时钟的两个缺点:
- 增加客户端的复杂性,需实现冲突解决逻辑;
- 向量时钟中的[服务器,版本号]数据对可能会迅速增长。可设定长度阈值,如果超过阈值,最老的数据对就会被移除。这可能会导致协调效率下降,因为无法准确地判断后代关系。
处理故障
系统架构图
写路径
读路径
总结
汇总如下表
目标/问题 | 技术 |
---|---|
存储大数据的能力 | 使用一致性哈希把负载分散到各个服务器上 |
高可用读 | 数据复制+设置多数据中心 |
高可用写 | 版本控制,通过向量时钟来制定冲突解决方案 |
数据集分区 | 一致性哈希 |
增量可扩展性 | 一致性哈希 |
异质性 | 一致性哈希 |
可调一致性 | 仲裁一致性 |
处理暂时故障 | 松散仲裁和暗示性传递 |
处理永久故障 | Merkle树 |
处理数据中心故障 | 跨数据中心复制 |
设计分布式唯一ID生成器
理解问题并确定设计的边界
需求如下:
- ID必须唯一;
- ID只包含数字;
- ID长为64位;
- ID按日期排序;
- 可每秒生成超过10000个唯一ID。
提议高层级的设计并获得认同
多主复制
多个数据库服务器搭建的集群,有如下重大缺点:
- 很难与多个数据中心一起扩展,需进行额外的同步和协调操作;
- 多个服务器同时生成ID,可能导致ID并不连续,也即ID并不随时间递增;
- 服务器添加或移除时,ID不能很好地随之变化。
UUID
维基百科:
每秒产生10亿个UUID且持续约100年,产生一个重复UUID的概率才达到50%。
优点:
- 简单:服务器之间无需任何协调,不会有任何同步问题;
- 易于扩展:因为每个Web服务器只负责生成它们自己使用的ID。ID生成器可很容易地随Web服务器一起扩展。
缺点:
- ID长128位;
- ID并不随时间增加;
- ID可能非数字。
工单服务器
利用中心化的单数据库服务器的自增特性。
优点:
- ID为数字;
- 容易实现,适用于中小型应用。
缺点是存在单点故障。
推特的雪花系统
解读:
- 符号位(1位):始终为0,留作未来使用。有可能被用来区分有符号数和无符号数;
- 时间戳(41位):从纪元或自定义纪元开始以来的毫秒数;
- 数据中心ID(5位):最多可以有32个( 2 5 2^5 25)数据中心;
- 机器ID(5位):每个数据中心最多可有32台( 2 5 2^5 25)机器;
- 序列号(12位):对于某个机器/进程,每生成一个ID,序列号就加1。每毫秒开始时都会被重置为0。
设计继续深入
41位能表示的最大时间戳是 2 41 − 1 2^{41}-1 241−1,即69年。69年后,需要一个新的纪元时间或采用别的技术来迁移ID。
总结
未讨论的议题:
- 时钟同步:假设生成ID的服务器都有同样的时钟,但这个假设并不成立。网络时间协议(NTP)是一个解决方案;
- 调整ID各部分的长度:对于低并发且长时间持续运行的应用,减少序列号部分的长度,增加时间戳部分的长度,生成的ID会更高效;
- 高可用性。