作为后端工程师,工作日常就是设计(信息)系统;后端工程师找工作也会被问到系统设计的问题。
分析流程
-
场景
- 汇总需求。
- 总结出最重要的功能性需求和非功能需求。
- 非功能需求,需要我们评估:
- QPS,peak QPS
- 磁盘占用
- 网络带宽占用
-
服务
- 如果是个大系统,需要先拆分成多个微服务
- 简单定义出数据模型和 API, 画出流程图
-
存储
- 数据库选型
- 难点分析,给出可行的方案
-
优化
针对难点,安全性,扩展性和易维护性,提出优化的建议。
非语言相关知识点
粗略估算
需要根据要求,粗略评估我们该如何设计系统,判断我们设计的系统能否满足性能要求。为此,需要熟知如下指标。
- 二次方
幂次 | 近似值 | 全名 | 缩写 |
---|---|---|---|
10 | 1千 | 1 Kilobyte | 1KB |
20 | 1百万 | 1 Megabyte | 1MB |
30 | 十亿 | 1 Gigabyte | 1GB |
40 | 万亿/兆 | 1 Terabyte | 1TB |
50 | 千万亿 | 1 Petabyte | 1PB |
对数(log)的量级,也要牢记。1百万的(对 2 取的)对数(log)大约是 20。
- 硬件延迟参数
操作 | 时间 | 1s 能做多少次 |
---|---|---|
访问 L1 缓存 (CPU 内部操作) | 0.5ns | 20亿次 |
Branch mispredict (CPU 内部操作) | 5ns | 2 亿次 |
访问 L2 缓存 (CPU 内部操作) | 7ns | |
互斥锁加锁/释放锁 (linux 内核操作) | 100ns | 1 千万次 |
内存引用(Main Memory Reference)(内存操作) | C/C++ 读写内存变量 | 100ns |
Zippy 压缩 1 KB 数据 | 10000ns = 10us | 十万次 |
在 1 Gps 网络上传输 1 KB 数据 (高性能网络的操作) | 20000ns = 20us | 五万次 |
从内存中顺序读取1MB 数据 (内存操作) | 250,000 ns = 250 us | 4千次 |
同一个数据中心往返一次 (可以理解为 ping 同一个数据中心中的另一台机器,耗时上限)(网络专线传输) | 500,000 ns = 500us | 2千次 |
磁盘寻道(Disk Seek) (外部存储 磁盘) | 10,000,000 ns = 10ms | 100 次 |
从局域网连续读取 1MB 数据 (局域网) | 10,000,000 ns = 10ms | 100 次 |
从磁盘连续读取 1MB 数据 (外部存储 磁盘) | 30,000,000 ns = 30ms | 30 次 |
从美国向荷兰发送一个 TCP packet (互联网环境下传输) | 150ms | 6次 |
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
- 需求是否需要支持事务
- 需求是否需要支持二级索引
读多写少,怎么应付读?
-
使用缓存可以应对读很高,写不高的场景。使用缓存有如下情况:
-
缓存的数据不可能在所有时间都和原数据保持一致。通常缓存是这样写的:
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/布隆过滤器) 来解决缓存击穿的问题。
-
-
添加索引,可以优化读性能。我们需要根据用户是怎么查询数据的,来建立合适的索引。
- 对于复合索引:建立索引时,注意最左匹配原则。
- 对于主键:
- SQL 的主键,优先选择单调递增的数字做索引(这样可以避免频繁的页分裂操作,提高读写性能)。
- NoSQL 的主键,通常不支持范围查询。
- 注意是否需要支持范围查询。
- 注意数据类型磁盘占用大小,尽量选择磁盘空间占用比较小的数据类型做索引。
-
读写分离。
- 关系数据库,可以采用读写分离来优化读性能。实现的时候需要考虑从节点复制滞后的问题。
- NoSQL 原生是分布式的,无需手动做读写分离。
如何处理写多的场景?
- 添加消息队列,利用消息队列削峰填谷的特性,异步处理。
- 数据库分片:
- 优先选择原生就支持分片的 NoSQL 数据库。
- 如果选择了关系数据库,就需要自己动手写分片的逻辑。这种做法,可以但不推荐。
- 采用了分片,不论是关系数据库还是 NoSQL ,都会使得能支持的操作受限(比如 join, in 等等操作,更难操作了);也会引入分布式事务的问题难以解决。
处理并发/竞争?
-
利用关系数据库的特性:
-
单对象操作的原子性,比如 seckill 模块中,更新库存的操作
update stock_info set stock = stock - #{num} where stock - #{num} >= 0;
-
使用合适的隔离级别(慎用通常不用 Serializable 性能太差), 或者显示加锁。(尽量少用,基本所有加锁方案都少用,影响性能)
-
-
利用 redis 的原子性。
- redis 是单线程的,单条命令(或者说一个请求)天生是原子性的。
- 使用 Lua 脚本可以自定义命令,这样我们可以将多个操作整到单条命令中,从而保证操作的原子性。
- 临时可以使用 redis setnx 命令,实现个分布式锁。但是这种方法存在争议,有人说 redis 故障恢复时,锁可能保证不了安全性。
-
利用 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, 移动端 等等的图标。
-
为方框涂色,代表特定的状态(比如功能是已经就绪的,还是在开发中;当前组件是热备还是冷备等等)。
-
可以使用方框或者分割线代表系统边界。
-
参考文献
- 数据密集型应用系统设计
- System Design Interview
- processon
- 由数据范围反推算法复杂度以及算法内容
- 详解系统架构的“4+1”视图
- 还不懂时序图,今天就来聊聊它
- 干货,3分钟掌握UML 类图
Q.E.D.