# 系统设计 **Repository Path**: externalflame/system-design ## Basic Information - **Project Name**: 系统设计 - **Description**: 记录系统设计相关的知识点。 - **Primary Language**: 其他 - **License**: MulanPSL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 2 - **Forks**: 1 - **Created**: 2022-05-20 - **Last Updated**: 2022-11-21 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 系统设计 ![简单的系统设计](./简单的系统设计.jpg) 作为后端工程师,工作日常就是设计(信息)系统;后端工程师找工作也会被问到系统设计的问题。 这个 repo 记录系统设计相关的知识和经验。 #### 分析流程 1. 场景 - 汇总需求。 - 总结出最重要的功能性需求和非功能需求。 - 非功能需求,需要我们评估: - QPS,peak QPS - 磁盘占用 - 网络带宽占用 2. 服务 - 如果是个大系统,需要先拆分成多个微服务 - 简单定义出数据模型和 API, 画出流程图 3. 存储 - 数据库选型 - 难点分析,给出可行的方案 4. 优化 针对难点,安全性,扩展性和易维护性,提出优化的建议。 ----- #### 非语言相关知识点 ##### 粗略估算 需要根据要求,粗略评估我们该如何设计系统,判断我们设计的系统能否满足性能要求。为此,需要熟知如下指标。 - 二次方 | 幂次 | 近似值 | 全名 | 缩写 | |----|-----|------------|-----| | 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 | 1 亿次 | | 互斥锁加锁/释放锁 (linux 内核操作) | 100ns | 1 千万次 | | 内存引用(Main Memory Reference)(内存操作) | C/C++ 读写内存变量 | 100ns | 1 千万次 | | 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 和 内存的延迟。但是在做算法题时,会关注。 [由数据范围反推算法复杂度以及算法内容](https://www.acwing.com/blog/content/32/) 总结了如何根据数据范围,评估我们需要哪种时间复杂度的算法。 - 可用性指标 业界使用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. 使用缓存可以应对读很高,写不高的场景。使用缓存有如下情况: - 缓存的数据不可能在所有时间都和原数据保持一致。通常缓存是这样写的: ```Python 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 模块中,更新库存的操作 ```sql 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/{problemId}。 - 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, 移动端 等等的图标。 ![web服务器](Web服务器.png) ![数据库](数据库.png) ![防火墙](防火墙.png) ![浏览器](chrome浏览器.png) ![ES](Elasticsearch.png) ![PC客户端](PC客户端.png) ![Redis](redis.png) ![分布式锁](分布式锁.png) ![商家](商家.png) - 为方框涂色,代表特定的状态(比如功能是已经就绪的,还是在开发中;当前组件是热备还是冷备等等)。 - 可以使用方框或者分割线代表系统边界。 ------------------------ #### 参考文献 - [数据密集型应用系统设计](http://ddia.vonng.com/#/) - [System Design Interview](https://www.amazon.com/-/zh/dp/B08CMF2CQF/ref=sr_1_4?keywords=system+design+interview&qid=1665301362&qu=eyJxc2MiOiIyLjk0IiwicXNhIjoiMi4xOSIsInFzcCI6IjIuMTIifQ%3D%3D&sprefix=System+desi%2Caps%2C420&sr=8-4) - [processon](https://www.processon.com/diagraming/) - [由数据范围反推算法复杂度以及算法内容](https://www.acwing.com/blog/content/32/) - [详解系统架构的“4+1”视图](https://zhuanlan.zhihu.com/p/352590602) - [还不懂时序图,今天就来聊聊它](https://zhuanlan.zhihu.com/p/334342146) - [干货,3分钟掌握UML 类图](https://zhuanlan.zhihu.com/p/267298708)