作为后端工程师,工作日常就是设计(信息)系统;后端工程师找工作也会被问到系统设计的问题。

分析流程

  1. 场景

    • 汇总需求。
    • 总结出最重要的功能性需求和非功能需求。
    • 非功能需求,需要我们评估:
      • QPS,peak QPS
      • 磁盘占用
      • 网络带宽占用
  2. 服务

    • 如果是个大系统,需要先拆分成多个微服务
    • 简单定义出数据模型和 API, 画出流程图
  3. 存储

    • 数据库选型
    • 难点分析,给出可行的方案
  4. 优化

    针对难点,安全性,扩展性和易维护性,提出优化的建议。


非语言相关知识点

粗略估算

需要根据要求,粗略评估我们该如何设计系统,判断我们设计的系统能否满足性能要求。为此,需要熟知如下指标。

  • 二次方
幂次近似值全名缩写
101千1 Kilobyte1KB
201百万1 Megabyte1MB
30十亿1 Gigabyte1GB
40万亿/兆1 Terabyte1TB
50千万亿1 Petabyte1PB

对数(log)的量级,也要牢记。1百万的(对 2 取的)对数(log)大约是 20。

  • 硬件延迟参数
操作时间1s 能做多少次
访问 L1 缓存 (CPU 内部操作)0.5ns20亿次
Branch mispredict (CPU 内部操作)5ns2 亿次
访问 L2 缓存 (CPU 内部操作) 7ns
互斥锁加锁/释放锁 (linux 内核操作)100ns1 千万次
内存引用(Main Memory Reference)(内存操作)C/C++ 读写内存变量100ns
Zippy 压缩 1 KB 数据10000ns = 10us十万次
在 1 Gps 网络上传输 1 KB 数据 (高性能网络的操作)20000ns = 20us五万次
从内存中顺序读取1MB 数据 (内存操作)250,000 ns = 250 us4千次
同一个数据中心往返一次 (可以理解为 ping 同一个数据中心中的另一台机器,耗时上限)(网络专线传输)500,000 ns = 500us2千次
磁盘寻道(Disk Seek) (外部存储 磁盘)10,000,000 ns = 10ms100 次
从局域网连续读取 1MB 数据 (局域网)10,000,000 ns = 10ms100 次
从磁盘连续读取 1MB 数据 (外部存储 磁盘)30,000,000 ns = 30ms30 次
从美国向荷兰发送一个 TCP packet (互联网环境下传输)150ms6次

CPU 内部操作的延迟 << 内存访问的延迟 << 高性能网络/拉了专线的局域网的访问延迟 << 外部存储磁盘的延迟 << 互联网的访问延迟

系统设计时,通常不会重点关注 CPU 和 内存的延迟。但是在做算法题时,会关注。 由数据范围反推算法复杂度以及算法内容 总结了如何根据数据范围,评估我们需要哪种时间复杂度的算法。

  • 可用性指标

业界使用99分位数,描述系统的可用性。具体如下:

分位数每天不可用时间每年不可用时间
99%14.4 分钟3.65 天
99.9%1.44 分钟8.77 小时
99.99%8.64 秒52.60 分钟
99.999%864 毫秒5.26 分钟
99.9999%86.4 毫秒31.56 秒
  • QPS

应用层,单台商用服务器最多能支撑 1K QPS。

数据层:

  • MySQL 不考虑分库分表,最高支持 1K QPS。
  • NoSQL 通常横向扩展性很好(可以认为是原生支持了分库分表),能支持远高于 1K 的 QPS 。

SQL 和 NoSQL 如何选择?
数据库类型QPS事务二级索引分片数据模式join
关系数据库1k支持支持原生不支持,需要手写,麻烦写时模式,不易频繁改动支持,但有join 的地方,可能带来性能问题
NoSQL远高于1K,且支持横向扩展通常不支持通常不支持原生支持读时模式,支持频繁改动通常不支持

mongodb 是NoSQL 中的异类,mongodb 非常像关系数据库。mongodb 支持二级索引,支持事务。

选择数据库,最重要的是看三点:

  • 需求的QPS
  • 需求是否需要支持事务
  • 需求是否需要支持二级索引
读多写少,怎么应付读?
  1. 使用缓存可以应对读很高,写不高的场景。使用缓存有如下情况:

    • 缓存的数据不可能在所有时间都和原数据保持一致。通常缓存是这样写的:

      class UserService:
          # 读
          def getUser(self, user_id):
              key = "user::%s" % user_id
              user = cache.get(key)
              if user:
                  return user
              user = database.get(user_id)
              cache.set(key, user, ttl) # 注意 ttl
              return user
      
          # 写
          def setUser(self, user):
              key = "user::%s" % user.id
              database.set(user)
              cache.delete(key) # 注意先 set, 然后再 delete, 通常在这一步不会 set
      

      这段代码也保证不了缓存与原数据完全一致,但是已经是 最佳实践了。

    • 缓存的作用是拦截大部分请求,需要避免用户频繁访问不存在的键,导致请求都落到 关系数据库的场景(缓存击穿)。通常使用 BloomFilter/布隆过滤器(比如 redis 安装插件后,可以支持 BloomFilter/布隆过滤器) 来解决缓存击穿的问题。

  2. 添加索引,可以优化读性能。我们需要根据用户是怎么查询数据的,来建立合适的索引。

    • 对于复合索引:建立索引时,注意最左匹配原则。
    • 对于主键:
      • SQL 的主键,优先选择单调递增的数字做索引(这样可以避免频繁的页分裂操作,提高读写性能)。
      • NoSQL 的主键,通常不支持范围查询。
    • 注意是否需要支持范围查询。
    • 注意数据类型磁盘占用大小,尽量选择磁盘空间占用比较小的数据类型做索引。
  3. 读写分离。

    • 关系数据库,可以采用读写分离来优化读性能。实现的时候需要考虑从节点复制滞后的问题。
    • NoSQL 原生是分布式的,无需手动做读写分离。

如何处理写多的场景?
  1. 添加消息队列,利用消息队列削峰填谷的特性,异步处理。
  2. 数据库分片:
    • 优先选择原生就支持分片的 NoSQL 数据库。
    • 如果选择了关系数据库,就需要自己动手写分片的逻辑。这种做法,可以但不推荐。
    • 采用了分片,不论是关系数据库还是 NoSQL ,都会使得能支持的操作受限(比如 join, in 等等操作,更难操作了);也会引入分布式事务的问题难以解决。

处理并发/竞争?
  1. 利用关系数据库的特性:

    • 单对象操作的原子性,比如 seckill 模块中,更新库存的操作

      update stock_info set stock = stock - #{num} where stock - #{num} >= 0;
      
    • 使用合适的隔离级别(慎用通常不用 Serializable 性能太差), 或者显示加锁。(尽量少用,基本所有加锁方案都少用,影响性能)

  2. 利用 redis 的原子性。

    • redis 是单线程的,单条命令(或者说一个请求)天生是原子性的。
    • 使用 Lua 脚本可以自定义命令,这样我们可以将多个操作整到单条命令中,从而保证操作的原子性。
    • 临时可以使用 redis setnx 命令,实现个分布式锁。但是这种方法存在争议,有人说 redis 故障恢复时,锁可能保证不了安全性。
  3. 利用 zookeeper/etcd 等。

    • 对于安全性要求严格的场景,还是 zookeeper/etcd 更让人放心。
    • zookeeper/etcd 通常不会直接暴露给应用程序使用(通常都是给数据库程序使用的,比如老版 kafka 就使用了 zookeeper)。我们要实现,可能增加额外的运维成本。

如何避免单点问题?

解决单点问题,归根结底是采用 复制 的办法。搞个备用的。

对于应用层的程序,通常来说是

  • 采用 无共享架构 :所有状态数据都保存到数据层的数据库中,应用层不保存数据,启用多台应用服务器保障高可用。
  • 使用 服务发现 技术,服务器宕机后,将用户访问路由到还可以正常工作的服务器。

对于数据库来说,通常数据都自己有方案解决单点问题,解决方案当然也是通过复制。


异步还是同步?

异步高效但是容易出现数据不一致的状况;同步低效但是能够保证数据一致性。

根据 CAP 理论,实际系统不可能既是 CP 的(在网络分区的情况下,仍然能够保证数据一致性),又是 AP 的(在网络分区的情况下,仍然保证系统可用)。

系统设计,可能需要在数据一致性和系统可用性之间做权衡。

举个例子:使用主从架构,如果我们增加同步更新的节点时,能增强数据一致性的保证,但是系统写性能会受到影响。如果我们只留一个同步更新节点时,写性能最优,但是可能丢失更新,数据一致性受到损害。

《System Design Interview》 Chapter 6. Design A Key-Value Store 更详细地介绍了如何在系统设计时做权衡。


Rest API 接口设计

Rest API 的设计目标是,前后端无需沟通,前端看到该 Rest API 就知道该怎么使用该 API。

为了达到这个目标, Rest API 定义了如下规范:

  • 你想要获取的数据是什么,路径的主目录就是什么。

    • 路径主目录通常是名词的复数

      • 我们在 leetcode 上查看所有题目,那么 API 的路径设计为 /api/problems 较为合理。
      • 我们要得到指定题目的提交,那么 API 的路径设计为 /api/submissions?problem_id=xxx 要比设计为 /api/problems/xxx/submissions 更合理。
    • 如果要操作指定的某一条数据。比如要查看id 为 problem_id 的一个问题,那么 API 路径应设计为 GET /api/problems/

  • HTTP 的四个方法 POST/DELETE/PUT/DELETE 分别对应 数据的 增/删/修/查 。

  • 所有筛选条件,创建参数都放到 HTTP 的参数或者请求体中。

  • HTTP 响应的返回码:2xx 表示成功,3xx 表示跳转或者缓存,4xx 表示客户端请求有问题,5xx 表示服务端有问题。

以上是 Rest API 的要求。设计 HTTP API 还要考虑其他因素,比如:

  • HTTP 请求参数长度(可能配置在 nginx.conf 中)远远小于 HTTP 请求的请求体大小(可能配置在 nginx.conf 中)。如果筛选条件,创建参数数据量可能会大,放到 HTTP 请求参数中,可能会被截断。应该优先放到 HTTP 请求体中。

  • HTTPS 会加密请求体,但是不会加密 URL 和 请求参数。所以如果筛选条件,创建参数包含用户个人隐私数据,优先放到 HTTPS 请求体中,加密传输。

  • PUT 通常暗示是幂等的。POST 通常暗示不是幂等的。(这不是一定的,仅仅是暗示)

  • 所依赖的工具对于 HTTP/HTTPS 的支持不全,导致无法按照 Rest API 设计。

    • 比如,之前公司的 ios 开发同事一直使用 AFNetworking 发送 HTTP 请求, AFNetworking 不支持 PUT 方法,所以为移动端提供的接口,都是 POST 的,不用 PUT 。
    • 比如我之前的公司,从外网访问内网的应用,需要走网关才能访问,而该网关(自研的基础设施)不支持 PUT 方法。导致很多接口不得不设计成 POST 的。
  • 开发小组内部规范。

    • 比如不少开发小组要求在响应体中,添加 code (也有叫 status 的), message 字段,分别描述业务状态,业务提示信息等等。
  • 对于某些信息,业界已经有了约定俗成的 API 格式要求。比如:

    • API 版本
    • Cookie
    • Csrf-Token
    • 跨域
    • 缓存
    • 国际化
    • 分页
    • 限流
  • 对于传递给后端的参数,需要检验其有效性,避免 sql 注入xss 攻击 等安全问题。


架构图

  • 4 + 1 规范描述了需要画哪些图,要描述哪些信息。(更详细查询参考文献中的 详解系统架构的“4+1”视图)其中比较重要的有:

    • 用例图,
    • 逻辑图,描述了功能的分组,分层关系,功能的状态等等。
    • 时序图,描述了业务活动的交互关系。
    • 部署图,描述了物理机是如何部署的。
  • UML 描述了该怎么画图(更详细的参考 《还不懂时序图,今天就来聊聊它》, 《干货,3分钟掌握UML 类图》)。常画的是 类图,时序图。 比如 类之间的 关联,组合,聚合,依赖,继承/泛化,实现关系该使用什么线表示。

  • 为了快速我们直接在白板上画图,也可以。不需要十分规范。

  • 如果要汇报,需要将图画的漂亮些。比如可以:

    • 保存些常见的组件图标,而不仅仅使用方框表示主题。比如可以使用 服务器,数据库,nginx, CDN, PC, 移动端 等等的图标。

      防火墙.png分布式锁.png商家.png数据库.pngchrome浏览器.pngElasticsearch.pngPC客户端.pngredis.pngWeb服务器.png

    • 为方框涂色,代表特定的状态(比如功能是已经就绪的,还是在开发中;当前组件是热备还是冷备等等)。

    • 可以使用方框或者分割线代表系统边界。


参考文献

Q.E.D.


谁言不解广寒情,天边一颗伴月星