Mike Zhang

DNS DevOps CISSP CISA Security+ 摄影 程序员 北京

系统架构设计实例及相关技术概念

26 Oct 2018 » Architecture

1. 系统功能简介

设计一个短链接网站,网站可以允许用户发送一些文本数据并生成一个共享的数据链接给其他用户访问,除了业务功能外(生成,编辑和删除),还需要具有用户管理功能(匿名访问,登入用户可修改编辑提交的信息).系统可支持 1000 万用户规模, 每月约生成 1000 万条记录, 1 亿左右的读取操作(10:1 读写比例).

2. 相关资源需求

每条记录 1KB 大小,数据库不会直接存储用户的内容,而是将其保存在服务器本地位置(text 格式文件). 因此记录中最长的应该是保存路径长度,设置 255-512 字节左右. 根据需求每秒约 40 个读操作,4 个写操作, 网络带宽要求较低,但是每个月磁盘空间新增占用: 1KB*1000 万=10GB

服务架构图

3.设计细节

数据库使用 MySQL 等关系数据库,仅仅用与保存元数据,所有用户提交的内容信息单独使用文件服务器进行存储.数据库仅保存存储文件的 url 链接.

文件存储可以使用 Amazon S3 或者开源的对象存储服务比如 Swift,Minio 等服务,这些服务都提供比较简单的接口用户管理文件的上传和使用.

当用户提交信息到服务器后, 调用 WriteAPI 将执行下列操作:

  1. 生成一个唯一的短链接 ID(注意检查是否数据重复)
  2. 使用 ID 保存用户的内容到对象存储服务.
  3. 用户信息,插入文件的链接信息,时间等作为一条记录插入到数据库中
  4. 返回用户新的访问 url
shortlink char(7) NOT NULL
expiration_length_in_minutes int NOT NULL
created_at datetime NOT NULL
paste_path varchar(255) NOT NULL
PRIMARY KEY(shortlink)

生成短链接的方式比较多,可以基于用户的 IP 和时间戳进行 md5 计算,然后对于 md5 编码进行 Base62 编码(区别与 Base64 中增加了+和/符号对于 url 不太友好, 最后根据需求进行截取操作,比如上面的 7 个字符长度.

url = base62_encode(md5(ip_address+timestamp))[:URL_LENGTH]

当用户访问的时候,通过查询数据库既可以看到是否该文件存在,如果存在则返回用户文件存储的位置或者直接是文本文件.

业务数据的删除, 可以设置一个定期的任务,扫描数据库表标记为删除状态或者直接删除.并将对应的文件删除.

4.扩展系统规模

系统设计最终如下图所示, 增加负载均衡,CDN 及 Cache 层,数据库也采用读写分离的方式进行设计扩展.整个过程逐步完成,根据需求进行添加.并且过程中需要对于性能进行不断的测试,找到其中的性能瓶颈. 分析数据库独立出来,作为数据仓库使用,采集日志进行实时或者离线的数据分析.

对于缓存数据的使用,需要将查询比较多的增加到缓存中,用户访问的时候先去查询缓存,如果已经存在的话则直接返回,否则查询后端数据库(读数据库)

4.1 DNS 服务

DNS 可以提供域名到 IP 的解析,利用 DNS 层可以实现流量的切换,负载均衡以及 A/B 测试.可以实现基于延迟或者地理位置的流量调度.同时访问 DNS 服务会导致一定的网络延迟,以及管理上的复杂度. Cloudflare 或者 Amazon Route53 都可提供相应的域名调度管理服务.

4.2 CDN 服务

使用 CDN 服务可以加速用户对于静态文件的访问速度,通过将这些静态文件分发到 CDN 服务商自己的数据中心,然后根据 DNS 将用户调度到地理位置上距离最佳的数据中心,现在一些 CDN 还可以提供动态文件的处理. 同时 CDN 还提供两种工作模式:

  • PushCDN: 用户将自身内容推送到 CDN,并重写自己系统资源访问 url 到 CDN. 同时需要用户自己管理存放在 CDN 上的资源创建,过期,更新等操作, 比较适合与小型网站(内容少,更新频率低).
  • PullCDN: 按需拉取, 当有新的资源访问时,CDN 负责从用户服务器拉取新的内容, 初始访问较慢,但是一旦缓存生效后,服务后期的查询相对较快.

当流量较大的时候,CDN 的使用花销较高,内容管理上也需要考虑更新数据与过期数据方面.同时服务需要修改 url 指向才可以正常提供 CDN 的功能.

4.3 负载均衡

负载均衡主要用于请求的分发,将相同的请求分发到不同的机器上执行,实现系统负载的平衡,防止一台机器的资源占用过高,并且可以实施健康检查,防止路由到不合适的子节点上,防止单点故障。

通过软件比如HAProxy或者硬件F5均可以实现负载均衡, 通过负载均衡我们还可以实施用户数据的SSL加解密操作,会话保持操作可以将相同cookie的用户路由到相同的子节点上(有时候web服务器支持本身通过共享数据库保存共享会话信息)

负载均衡一般使用AP(主被模式)或者AA(双主模式)来防止负载均衡本身的单点故障。AP模式下,主服务器的心跳信息一旦出现问题,被服务器将配置为主的IP提供服务。AA模式下则两个服务器同时提供服务,使用DNS解析等方式实现双活。

四层负载均衡: 主要是处理基于传输层的网络数据包负载,使用NAT来代理用户的请求到后端。

七层负载均衡: 通过请求的header, 消息,cookie等信息来决定如何进行数据包的分发和管理。复杂度较高,实际运行的资源消耗也比四层要高。

与反向代理的区别: 反向代理使用一个web服务器来接收所有的请求并将请求路由给后端服务器,通过使用反向代理可以隐藏后端的服务,设置黑面单和连接数量。可以使用反向代理来进行缓存,SSL处理,静态文件处理以及灵活的配置后端服务。反向代理可以仅仅代理一个后端服务,但是负载均衡一般必须设置多个才有意义参考链接

4.4 水平扩展

水平扩展指的是使用大量的服务器来扩展服务的能力,这些服务器可以是普通的服务器即可,因此大规模的应用下相对比较廉价,而垂直扩展则使用较高的硬件来弥补系统的不足和压力。特殊的硬件不仅昂贵,维护费用也相对较高。

水平扩展同样面临很多的问题,不如说用户相关的会话信息管理(考虑使用中心化的数据库保存),但此时的数据库可能需要面临很多并行的操作和处理。

4.5 应用设计

网站的应用设计可以根据单一职责原则进行设计,某一些服务独立提供API接口给其他服务使用,微服务的设计可以更有利于服务的重构和管理。当有大量的服务同时运行的时候,一套合适的服务发现系统可以减少整个系统的管理成本,使用Consul, Etcd或者ZooKeeper等能够提供服务去查询到其他的服务并实时获取最新的服务连接信息,健康检查可以自动的修改和管理服务的对外接口信息。

Consul或者Etcd都具有K-V存储功能,用于管理所有应用的配置信息和管理信息。

微服务同样会带来部署和管理上的问题

4.6 数据库管理

关系数据库的事务包含四个方面:

1. 原子性: 每个操作要么执行,要么不执行 2. 一致性: 任何事务对于数据库的修改都是有效的一致的 3. 隔离性: 并发执行和串行执行结果应该一致 4. 持久性: 一旦一个事务提交,它将被持久存储下来。

数据库的扩展主要包含以下几个方面:

1. 主备复制

Master服务器用于接收用户的读和写操作,并复制所有的写操作到Slave服务器,这些Slave服务器仅仅用于读取操作,Slave也可以再设置新的Slave作为一个树形结构。如果Master服务器出现问题,系统将运行在只读模式下,并提升一个Slave成为Master服务器。

需要处理主备故障切换的逻辑, 同时会存在潜在的数据丢失现象,Slave服务器过多也会导致复制的流量较大,多层的Slave在读取时候存在数据延迟现象。很多数据库在复制数据的时候以只允许串行的方式进行写入Slave。

  1. 双主复制

双主模式下,两台服务器同时接受用户的读写操作并彼此保持数据的同步, 一旦任何一个宕机,另一个系统将接管所有的查询和写入操作。

双主模式下,写入的延迟可能较高(同步确认环节), 同时数据操作的冲突比主备模式更明显。另外也存在很多的主备模式下的问题,比如数据丢失以及延迟等。

  1. Federation模式

该模式下数据库按照功能进行切分,比如下图根据用户或者产品进行切分数据库,每个数据库的读写数量得到减少,同时可以提高并行写入的能力。

Federation的问题是如果你的数据查询需要跨越多个库的时候,会相对较慢,应用程序的逻辑需要重新设计,数据库的Join比表Join更加复杂,因此实施的复杂度较高。

  1. 分片技术

将数据按照一定的规则进行分组存放在不同的数据库中,每个数据库包含一部分数据,比如用户数据库,可以按照用户首字母进行分类如下图所示, 具有和federation相同的优势,缓解读写压力,并且每个数据集的索引页更小,查询的更快。如果一个数据库无法工作,不会影响所有的用户的访问。

应用需要修改满足shard要求,可能会导致比较复杂的sql查询, 数据分布是否均衡需要考虑(用户的首字母分布很不均匀)数据的join操作更加复杂。

  1. 去归一化

去归一化主要是为了增加读的效率,不用每次都去做复杂的join操作,将数据冗余存放,一些数据库本身支持去归一化后数据的一致性拷贝(Oracle, PostgreSQL)。 对于上面的分片和fed,借助于去归一化可能更容易实现。 大部分系统的读写比例会超过100:1, 因此复杂的读取操作可能会导致响应过慢,用户体验较差。同时,去归一化数据库的写操作可能受影响,需要执行数据的同步修改

  1. SQL调优

使用一些压测工具或者profile工具来查看数据库的实际运行情况。一些简单的优化措施包括:使用char替代varchar, 使用int存储超过40亿的数字,避免使用blobs(而是考虑只存储位置信息)。 使用not null限制来提升搜索性能, 使用decimal存储float。 使用index增加查询速度, 但index也会导致写入的速度较慢,当一次性导入数据的时候,可以关闭index,然后导入后重建index。 index载入到内存中,因此会导致内存消耗增加。

[参考资料](https://camo.githubusercontent.com/1df78be67b749171569a0e11a51aa76b3b678d4f/687474703a2f2f692e696d6775722e636f6d2f775538783549642e706e67) [SQL优化](http://aiddroid.com/10-tips-optimizing-mysql-queries-dont-suck/)

  1. NoSQL和SQL的区别

SQL主要是结构化数据存储,严格的查询语句和Schema, 存储关系型数据以及事务处理。使用index来加速查询,join来进行关联查询等等。

NoSQL则存储的是半结构化数据,动态灵活的Schema查询,数据往往没有关联,可以支持TB甚至是PB的数据,读写速度较快,比如日志数据,缓存或者临时数据等等。

4.8 缓存

缓存能够极大的提升查询的效率,提高页面的载入时间缩短服务器和数据库的负载。如下图所示,dispatcher负责所有的客户端查询工作,如果请求之前被缓存则直接返回,否则调用后端的worker执行查询获得的结果进行缓存。

缓存存在多个地方:

  1. 浏览器缓存
  2. CDN缓存
  3. Web服务器缓存(Nginx等提供的静态或者动态文件缓存)
  4. 数据库缓存
  5. 应用服务器缓存(Memcached或者Redis提供缓存)

其中应用缓存部分一般可以基于LRU算法执行缓存的失效,将一些不再被查询的数据删除掉,同时如果使用redis还可以实现额外的比如持久化以及内建的排序列表等结构。

缓存可以基于数据库查询,将查询语句作为key(或者hash过后的)缓存查找使用。但是一旦一个表内容发生变化,需要执行所有的相关查询的缓存删除

缓存可以基于对象数据,比如web页面数据,用户session数据等等

缓存更新的时候需要使用不同的策略去操作缓存,

第一种方式CacheAside的方式:

应用本身去管理缓存的插入和删除以及更新操作。这样只有被查询的数据才会被缓存所存储,更新等,也被成为惰性加载缓存。第一次的时候会导致比较麻烦的查询的更新操作,可以设置缓存到期时间,防止其他的应用更新数据,

第二种方式Write-Through

缓存作为连接上层应用和下层存储的桥梁,读写操作直接作用在缓存上, 写操作频繁的将会导致平均时间较长,且占用缓存资源。

第三种方式是Write-Behind

缓存和存储通过消息队列的方式进行管理,可能导致数据的丢失,实施起来也比其他的更加的复杂:

第四种方式是Refresh-Ahead

配置缓存自动刷新任何最近访问且要到期的缓存数据, 这种方式将减少延迟并且通过缓存内部的预测可以改善查询性能。

参考材料

4.9 异步操作

对于一些执行时间较长或者消耗资源较多的请求,我们通过异步的方式进行处理,将用户的请求放入消息队列,而队列的另一端提供大量的worker消费这些信息,执行操作并将结果写入到消息队列或者其他的数据库中。

Redis可以作为消息队列,但是消息容易丢失 RabbitMQ消息队列可以保证消息的可靠传递但是配置使用比redis复杂 AmazonSQS可能导致延迟过高以及数据重复发送问题

可以使用类似于Celery这样的库来管理任务队列,注意运行压力的问题,如果队列已满无法及时处理,是否会导致消息的丢失, 应该设置一个机制去返回用户当前任务不可执行或503状态码,使用backoff算法等等

参考链接: 如何缓解负载压力