「从小白到架构师」系列努力以浅显易懂、图文并茂的方式向各位读者朋友介绍 WEB 服务端从单体架构到今天的大型分布式系统、微服务架构的演进历程。本文是「从小白到架构师」系列的第一篇,主要讲述提升网站吞吐量、应对更高并发量的主要技术手段。
相信很多朋友都搭建过个人博客之类的后端系统,这类系统的架构非常简单:
首先购买一台云服务器,并在上面安装 MySQL 数据库,然后部署一个 node.js 之类的 HTTP 服务器监听 80 和 443 端口,在 node.js 中连接数据库并实现业务逻辑。最后购买一个域名并配置 DNS 记录指向我们的服务器 IP 地址,这个网站就算搭建完成了。
随着不断的努力,我们网站的访问量越来越多。某天早晨当你美滋滋打开网站想要看一眼最新评论时,却发现网站打不开了。。。
登录服务器查看日志后发现因为访问人数过多,MySQL 已经无法及时响应所有的查询请求,看来有必要进行一波优化了。
缓存
在博客、新闻、微博、(短)视频、电商等大多数业务场景下读取请求的次数要远远大于写入请求的次数,且读取集中在少数热门数据上而长尾数据很少被访问。在这样的场景中我们可以通过加缓存的方式来提高网站处理读取请求的并发量。
Redis 是一种比较常用的缓存系统,它是 Key-Value 结构的内存缓存。Redis 作为独立进程运行并通过 TCP 协议提供服务,这意味着不同服务器上的业务进程(如 node.js) 可以连接到同一个 Redis 实例并共享其中的数据。
由于数据在内存中 Redis 的访问速度要远远大于基于磁盘存储的数据库(单个 Redis 实例可以达到 每秒10万次读写,而 MySQL 只能达到每秒百次写入或千次查询)。但是内存的价格比 SSD 昂贵很多,可用的内存空间非常有限,这要求我们妥善设计缓存方案以及淘汰策略,在缓存命中率和内存消耗之间取得合理的平衡。
使用缓存是一种有效的提高系统吞吐量的方案,但要注意处理缓存一致性、缓存穿透、缓存雪崩等问题。
Redis 官方提供了 Redis Cluster 作为集群解决方案,社区中也有 Codis 等优秀的代理式集群解决方案,AWS、阿里云、腾讯云等云服务商都提供了商业化的 Redis 集群。在单机版 Redis 吞吐量不够用时,我们可以方便的迁移到 Redis 集群上。
负载均衡
缓存抗住了大部分的访问请求,随着用户数的增长,现在并发压力主要落在单机的业务服务器上。
一种升级思路是提高单台服务器的配置比如4核8GB内存升级到8核16GB内存,这种思路称为纵向扩容;另一种思路是提高服务器的数量,使用多台服务器同时处理请求,这种思路称为横向扩容。相对于不断增加的访问量,单机性能的提升空间却极其有限,所以在实际工作中更多的采用横向扩容的思路。
我们使用反向代理软件 Nginx 代替业务服务器监听端口,在多台云服务器上部署业务服务器,并将这些业务服务器配置为 Nginx 的后端服务器组。来自用户浏览器的 HTTP 请求首先到达 Nginx, Nginx 根据我们配置的规则将请求转发给负载较低的一台业务服务器,在收到业务服务器响应之后将其返回给用户。
业务服务器的数量可以根据当前的访问量随时增加或减少,在高峰期增加服务器保证质量,低谷期减少服务器节约成本。
我们都知道在电脑 A 上「复制」一个文件是不能在电脑 B 上进行「粘贴」的,同理一个用户的第一次请求被路由到业务服务器 A 第二次请求被路由到业务服务器 B 也会产生类似的问题。抽象一点说,第一次请求改变了业务服务器 A 的状态,而第二次请求的正确响应依赖于业务服务器的状态,在「复制-粘贴」这个例子中「粘贴板」的状态决定了是否能够正确处理「粘贴」请求。
聪明的你可能会说:那么同一个用户的请求始终路由到同一台业务服务器就可以了?我们复习一下上面这句话:「业务服务器的数量可以根据当前的访问量随时增加或减少」,也就是说保存了用户状态的业务服务器可能会被我们回收掉,在高峰期某个用户可能会被分流到新的服务器。
这是一个非常难以解决的问题,所以业界通常的思路是解决问题本身,即:业务服务器无状态化。业务服务器应该像纯函数一样,输出完全由输入决定,自身不存储任何数据,也不维护任何状态。无状态的服务器可以随时启动和停止,服务器的数量也可以随时增加或减少。某台服务器故障后,它未完成的请求也可以转移到其它服务器上重试。当然业务服务器无状态不代表业务逻辑无状态,所有的状态都应存储在数据库
单台 Nginx 的性能虽然很高但仍是有极限的,同样的思路我们可以将负载分布在多台 Nginx 上。Linux Virtual Server 是工作在 TCP 层(OSI 四层)的负载均衡器,是业界常用的 Nginx 负载均衡方案。
由于 LVS 是单机版的软件,若 LVS 所在服务器宕机则会导致整个后端系统都无法访问,因此需要有备用节点。可使用 keepalived 软件模拟出虚拟 IP,然后把虚拟 IP 绑定到多台 LVS 服务器上,浏览器访问虚拟 IP 时,会被路由器重定向到真实的 LVS 服务器,当主 LVS 服务器宕机时,keepalived 软件会自动更新路由器中的路由表,把虚拟 IP 重定向到另外一台正常的 LVS 服务器,从而达到 LVS 服务器高可用的效果
如果 LVS 也扛不住了呢?不用着急,在 DNS 服务器中可配置一个域名对应多个 IP 地址。DNS 服务器可以按照负载均衡策略将域名解析到其中一台 LVS 的 ip 地址,从此系统可*的进行横向扩容:
在上面这张架构图中除了数据库外的组件都不是单机运行的,单台机器故障不会导致整个系统宕机,任何一个组件容量不足时都可以通过加机器迅速扩容。这是分布式系统中另一个重要的原则:消除服务器内单点
数据库篇
经过缓存和横向扩容,我们的网站已经可以应对高并发的读请求以及业务逻辑计算的开销。但是我们写入的吞吐量仍然受限于单机数据库,那么有没有办法解决数据库的单点问题呢?
读写分离
包括 MySQL 在内的绝大多数主流数据库均支持主从复制,从库会监听主库的更新并将更新同步到本地,从而始终保持与主库的数据集一致。
从库除了作为备份之外也可以像缓存一样分担主库的读取压力,即数据更新写入主库而查询操作则在从库上进行,我们将这种技术称为读写分离。
一些复杂的查询会消耗数据库大量的 IO 和 CPU 资源,举例来说:我们将关注关系存储在 MySQL 中,而计算用户粉丝数的 select count
查询非常耗时,我们可以将这样的查询移到从库上进行,主库的资源则可以用来处理更多写请求。
分库分表
在读写分离一节中我们配置了多个用于处理读取请求的从库,但是处理写入请求的主库始终只有一个,主库仍然是制约整个网站的吞吐量的瓶颈。那我们能否像读库一样配置多个主库,以此来提升网站写入的吞吐量呢?
答案是肯定的,使用多个主库的核心问题在于如何决定某一条数据应该写入哪一个节点中。比如用户 A 发表了一篇文章我们将它存入了数据库 1,后续查询时我们却到数据库 2 中进行搜索,自然一无所获;又或者用户 A 的第一篇文章存入了数据库 1,第二篇文章却存入了数据库 2,在我们按时间查询用户 A 的文章时就不得不搜索每一个数据库然后费时费力的将结果重新排序。
决定数据写入哪个节点的策略我们通常称为分表的路由策略,选择路由策略的原则是尽可能的将需要一起使用的数据放到同一个数据库中,避免跨库带来的额外复杂度。比如在博客系统的场景中,我们通常会将同一个用户的文章放入同一个数据库。
接下来的事情就是如何将用户 ID 映射到某个表上了。最简单的方法是 hash(user_id) % db_num
, 但实际场景中节点的数量会发生变化(即扩缩容),此时几乎所有数据的 db_id 都会发生改变,在扩缩容过程中需要迁移大量的数据。因此,在实际使用更多的是一致性哈希算法,它的目标是在数据库节点数量变化时尽可能的减少需要迁移的数据量。
无论如何选择分表路由策略我们都无法完全避免进行跨表读写,这时有一些额外的工作需要处理,比如将多个数据库返回的结果重新进行排序和分页,或者需要保证跨库写入的 ACID (事务)性。此时就要使用诸如 MyCat 这样的数据库中间件来帮我们处理这些麻烦事了。
和单机数据库一样,分库分表架构下同样可以为数据库节点配置从库,一是可以用作备份,二是用来实现读写分离。
NewSQL
MySQL 以数据页为单位进行存储,每个数据页内按主键顺序存储着多行数据。在写入新数据时首先需要读取主键索引找到对应的数据页,然后将新的数据行插入进去。必要时还需要要将原来一页中的数据转移到其它数据页上才能满足页内按主键顺序排列的要求。 这种由于一次数据库写入请求导致的多次磁盘写的现象被称为写放大,随机读写和写放大是制约 MySQL 写入性能的主要瓶颈。
本文描述基于 MySQL 默认的 InnoDB 存储引擎, InnoDB 同时也是 MySQL 中应用最广的存储引擎。本文不强调 MySQL 和 InnoDB 的区分。
LSM-Tree 是一种日志式的存储结构,对数据的增删改都是通过在日志尾部追加一条新记录实现的。由于不需要寻找数据页和维护页结构只需要进行顺序写,日志式存储结构的写入性能大大优于 MySQL 这类面向页的存储结构。
LSM-Tree 结构数据库的经典代表是 RocksDB 和 LevelDB, 很多新一代的数据库(NewSQL)的底层均使用 RocksDB 或 LevelDB 作为存储引擎。比如大名鼎鼎的 TiDB 便是以 RocksDB 作为存储引擎,在其上通过 Multi-Raft 协议构造高一致性、高可用、支持快速扩缩容的分布式数据系统。
图片源自 tidb 官网: https://docs.pingcap.com/zh/tidb/dev/tidb-storage
直接使用 TiDB 之类的分布式数据库可能是比自行分库分表更简单高效的方案。除了 TiDB 外还有各类 NewSQL 活跃在业界解决着传统关系型数据库难以解决的问题,比如用于进行复杂统计查询的 Hive、用于进行模糊搜索的 ElasticSearch、用于存储和分析海量日志的 ClickHouse 等时序数据库、用于计算共同好友等场景的 Dgraph 等图数据库…… 这些新型数据必将极大的提高开发效率和系统性能。
消息队列
消息队列在应对高并发上也是一种非常有用的技术,这里消息队列有两种用途:第一是用作限流,用户请求先进入消息队列排队,然后慢慢送到业务服务器进行处理,起到削峰填谷的作用,可以用来应对秒杀等瞬间峰值的场景;第二是异步处理任务,比如订单创建成功后立即返回,通知发货等逻辑通过消息队列进行异步处理,从而减少请求处理时间。
总结
应对高并发
我们从最简单的单服务器+单数据库架构开始,通过缓存和读写分离技术提高读取吞吐量,通过横向扩容提高业务服务器容量,通过使用分库分表技术提高数据库写入能力。最后兼具高性能、高一致性的新一代的分布式数据库系统 ———— TiDB。
缓存、横向扩容、通过 MQ 异步执行是在业务开发中最常用、成本最低的提高吞吐量的方案。新一代的分布式数据库系统替我们解决了传统关系型数据库单点运行、吞吐量有限、难以横向扩容、应用场景局限等问题,各大厂商正在越来越多的将 NewSQL 应用于生产环境中, 学习使用新一代数据库技术必将极大的提高开发效率和系统性能。
走向分布式系统
在本文中我们应对吞吐量不足的核心思路是将单机系统改造为分布式系统,很多同学一提到分布式系统便想到 CAP 理论、Paxos 算法、Hadoop 等吓人的名词,然后就没有然后了。
在本文中我们提到了两种分布式系统,第一种是在「负载均衡」一节中提到的无状态分布式系统,这类系统结构比较简单通常由负载均衡+业务服务器组成,由于无状态的特性可以随意扩缩容。第二种便是比较复杂的有状态分布式系统,具体的讲就是各种分布式数据库(包括内存数据库),幸运的是厂商准备好了开箱即用的方案,倒也不必为此花费过多心力。
本文中提到的「分库分表 + 主从复制」是大多数分布式数据库的基本思想,分布式数据库面临的主要难点是系统内的拓扑是动态变化的:现在数据库中有几个主节点在正常工作?这些主节点的地址是什么?那些节点发生了主从切换? 分布式数据库需要让系统内所有节点对系统的拓扑结构的认知始终保持一致,否则便会出现应该写入节点 A 实际上写入了节点 B 这样的错误情况。有时间我会专门写一篇文章来介绍分布式数据库的相关知识。
下集:应对业务的复杂度
在本文中我们重点关注负载均衡、数据库、缓存等基础设施,对于业务逻辑一笔带过。在实际工作中业务逻辑却是复杂、多变的,业务代码在不断迭代也更容易出错,「从小白到架构师」系列第二篇将讲述单体架构到微服务的演进历程,从系统架构角度研究如何控制业务复杂度、包容业务系统故障。