#### 第22章: #### 系统架构 PHP集成环境也能跑程序,为什么要做系统架构?都是服务端服务,为什么说根据不同的场景系统架构不一样? 开发一个系统,通常会经过需求分析、系统分析、设计、建模、测试等过程。经过这些过程将程序上线,有时又会出现各种情况,比如由计算机硬件指标不够、计算机系统移植性不够、带宽不足、数据库设计不足、等导致的软件崩溃、客户请求超时、CPU拉满、I/O缓慢等等情况。于是我们重新设计系统架构,重写应用程序代码,重构系统,尝试找到合适的架构方案使系统能够在低成本的情况下做到高可用、高性能、数据安全。所以一个优秀的系统架构一定是精准合适而且性能良好的。 这里我们引出系统架构目的是为了解决具体问题并具备逻辑性。 #### 22.1 系统架构难点 对于新手来讲,优秀的系统架构设计基本上是做不出来的。设计系统架构需要完善的计算机知识体系以及各种软硬件使用、运维经验,以及对业务深入理解的程度并拥有依据业务选择选择合适的技术栈的能力。所以设计系统架构是需要一定要求的。 系统架构设计有技术难点和业务难点。 对于大型系统来说,有以下高频场景: - 高并发场景:需要在短时间里快速处理密集型大量请求,保证高性能。 - 高可用场景:需要在极端条件下,系统可用。 - 高扩展场景:流量及系统变化,以最少的代价支撑业务扩展。 - 分布式一致性场景:需要在分布式保证数据的正确性、一致性,比如在数据库的分布式场景中有些会采取`补偿`。 ...... 系统架构的技术难点在于如何在代价合适的情况下关联硬件技术、软件技术使系统具备高可用性、高性能、可扩展、数据安全、高容灾性等特性。 系统架构的业务难点在于如何熟悉理解复杂的业务调用链路并创建合适的具备高可用性、高性能、可扩展、数据安全、高容灾性等特性的控制流代码。 #### 22.2 设计系统架构需要考虑全局 单机硬件指标从来不是一个系统的性能指标。就像是一个篮球队有一个两米高篮球水平一般的男生(硬件满配,软件能力一般),四个一米六高篮球水平一般的男生(硬件一般、软件一般),打区域比赛时它们输给了另一个由五个一米七篮球水平很高的男生组成的篮球队。试想以下,某个高并发场景调用链有五个节点,第一个节点拥有强大的处理能力所以吞吐量高,它将它二十毫秒处理完的大量信息发送给第二个节点处理,第二个节点从接收到信息那一刻由于运算能力不足就开始阻塞,然后花了2秒处理完后将更大量信息交由第三个节点,第三个节点又开始阻塞,直到信息到第四个节点再传到最后第五个节点完成时已经过了很长一段时间。 设计一个系统应当从整体能力考虑,并且应该注意所有指标的最低能力。对于一个大部分硬件、软件、网络、容灾、高可用等指标都良好的系统,调用链出现一个低于整体平均能力过多的节点将是灾难性的。 :-: ![](https://img.kancloud.cn/72/36/72365b58a55b85749c95ea3ed326b5ec_650x222.png) ​ 由某一最低能力节点导致整个系统性能变差的调用链 #### 22.3 系统设计需要考虑使用场景和需求 对于一个需要即时响应的系统,整个系统架构的硬件、软件、网络配置都应该在合适的代价下资源达标并创建良好的控制流代码使程序尽力使用这些资源良好运行。 对于不需要即使响应的系统或者业务,可以视情况不增加代价或者降低代价。比如白天负责收集,晚上可以花一整晚出结果的系统,再比如一个请求到来可以在5分钟内(长时间)响应的系统。 #### 22.4 几种技术架构案例 在以PHP语言为主的后端服务器架构里,高并发和高负载的约束条件通常是: 1. 硬件 2. 部署 3. 操作系统 4. Web 服务器 5. PHP 6. MySQL ##### 22.4.1 最简单架构 :-: ![](https://img.kancloud.cn/bd/c0/bdc009187cc9712c02cdedaa7c5e4c09_550x200.png) 这是以PHP为后端语言系统的最基础架构。对于并发要求不高的网站系统或者公司管理系统,在这样架构的基础上,做好MySQL的数据安全,选择适当的网络带宽和硬件配置,就可以应对日常系统使用。适当的情况下可以分离WebServer、后端程序、数据库程序,比如可以使用代价小的云存储MySQL,其稳定高效。对于WebServer,如果是静态页面较多使用资源少的情况推荐使用Nginx,如果追求WebServer稳定的情况下可以使用Apache。 ##### 22.4.2 最简单架构的延申架构 - 注重负载均衡的架构 :-: ![](https://img.kancloud.cn/ad/e1/ade13bf68d6f86dd72fcf74deadd2629_667x344.png) 如果WebServer传到后端程序的数据量较大,而后端程序处理能力不够导致数据排队甚至丢包、超时,后端程序和数据库交互没有问题的情况下,可以增加后端程序节点做负载均衡。这样可以按需分配后端程序群算例,便于弹性伸缩。 - 注重负载均衡和数据库数据响应的架构 :-: ![](https://img.kancloud.cn/25/fc/25fcdf4df23550ceaa1a84021d96b315_796x326.png) 如果需要后端程序与数据库交互速度更快,可以考虑将长期数据进行数据缓存,增加一层缓存并创建良好的缓存控制流代码防止缓存穿透、击穿。 - 注重负载均衡、数据库响应、NoSQL数据库安全、缓存数据能力的架构 :-: ![](https://img.kancloud.cn/9f/bf/9fbf8c6da4cf02153737d0cc130304de_892x329.png) 如果需要让数据库之前有一层`缓存--数据库`层加快响应且做内存数据库,并希望缓存层的容灾能力、并发能力强,数据安全,可以在MySQL之前将DBCache层用Redis集群或者Redis哨兵替代。 Redis哨兵模式的优势:主从节点是全量数据库,所有的键值全部都会保存在主从节点,做主从复制能力强。 Redis哨兵模式的劣势:在大量读/写请求事件到来时并发能力稍弱。由于全量化存储浪费内存导致容量有限。 Redis集群的优势:分布式每个节点分片存储键值数据,节点之间互相监控并转发请求事件。解决了Redis单机容量问题。 Reids集群的劣势:数据量小的情况下部署复杂。集群模式没有读写分离,客户端请求任何一台集群里的节点会被计算键是否在本节点的槽中,如果在直接执行命令,如果没有转发给对应槽节点执行命令。Redis集群一共有16384个槽,可以任意分配给各节点。 特别说明:不是Redis层与MySQL层交互,它们之间没有对应的交互API,上图Redis与MySQL之间的箭头是抽象的逻辑交互。实际上是控制代码由后端程序与Redis交互,后端程序与MySQL交互。只是通常控制后端程序先查询Redis缓存,没有数据的情况下再控制后端程序查询MySQL,并且将MySQL的数据放入Redis缓存以便下次快速查询。当然,也可以根据需要直接控制后端程序使用Redis作为NoSQL。 - 注重负载均衡、数据库响应、NoSQL数据库安全、缓存数据能力、数据库能力、数据库安全的架构 :-: ![](https://img.kancloud.cn/31/b4/31b4677505819de023f4d9842b830982_892x329.png) 如果需要再让数据库能力提高,可以部署MySQL集群。 MySQL集群的优势:数据安全,容灾能力强,读写分离带来的效率。后端程序可以控制MySQL集群让某从节点某库的只负责某功能,增加了MySQL查询的伸缩性。 #### 不同业务面对的架构 - 即时响应的服务分离架构 :-: ![](https://img.kancloud.cn/18/95/189581ac9f45e4b1313e365fa3e8328a_907x887.png) 如果需要将服务独立出来单独处理,可以使用这样的架构设计,可以看作是服务分离架构(微服务架构)。适当的情况可以增减某项服务部署的主机数量,再利用Nginx Review指向某项具体服务。多机服务的情况下需要注意Session共享问题,可以使用Redis等再做一层Session服务器。 - 非即时响应的服务分离架构 :-: ![](https://img.kancloud.cn/18/88/188802e7e6702cd08d9a24cd9a17f659_1294x872.png) 笔者曾经在某一家非即使响应的业务分离的技术架构为主的公司工作,并且对此架构印象比较深。这个系统解决餐饮行业的一系列问题,比如点单,数据统计等。其中在中午的时候点单量特别大,但是打印订单并不需要马上响应,20秒内就可以,如果同一时刻订单量小的情况马上打印订单也是可以的。还有比如盘点业绩的时候可以打印统计报表、营业报表等信息,也不需要马上响应,20秒左右通过打印机打印出来就可以。值得深思的是,对于这套系统,接收的总的流量很大,但是公司所花的代价却很小。这是为什么呢?原因是在分离了服务,加强了平时压力大的服务集群,并且所有服务之间的调用全部使用策略合适的消息订阅的推送模式。服务之间是生产者和消费者的关系,服务与基础服务之间也是生产者和消费者的关系。这样避免了某一个服务链路集中吞吐造成堵塞,而且可以集中强化高吞吐的链路节点(包括消息队列服务器节点),甚至分离消息队列服务器独立于某一项服务。 ##### 业务分离架构的优势 业务分离的架构的优势:具体服务只负责具体的业务,整个系统解耦,如果一个服务崩溃的情况其他服务不会崩溃;增强了单独业务处理模块的能力以及伸缩性;可以使用多语言生态,也就是说具体服务模块可以使用PHP、Java、C#、Golang等。 业务分离的架构的劣势:调用链复杂,在调用路径中一般需要一层一层带上许多信息,并且由于调用链复杂的情况下会带来调试复杂和增加创建良好控制流代码的难度。数据库实现数据一致性难度增加,由于服务分离后多个服务互相调用并且都会使用到数据库带来的数据一致性问题、事务问题,常常需要一个繁杂的过程去保证,比如补偿行为。 现在许多公司在使用微服务方向的架构,包括腾讯、阿里巴巴、美团等。 ##### 使用消息队列中间件的优势 消息队列带来的好处主要是解耦、异步、削峰。 解耦:服务独立,运行时关联性可以解开。 异步:不用等待就可以处理其他控制流。 削峰:当大量请求到来时加入消息队列,后端服务拉取或者得到消息推送并且一个个处理,数据库I/O也在能够承受的范围,不会造成服务器性能降低甚至挂掉。 #### 其他架构 - Swoole运行模式的架构 :-: ![](https://img.kancloud.cn/59/17/5917bc3b286d1ef09a461268aa1bf462_550x290.png) Swoole给PHP带来一定的变革,使PHP工作进程有常驻内存的能力,更小资源消耗的协程,更丰富的socket编程方式。Swoole运行模式的架构用Nginx处理静态文件,因为Nginx对静态文件处理能力强大,如果是动态请求交予Swoole处理。可以适当的在Swoole层、DBCache层、MySQL层进行扩展。 当Nginx没有找到静态文件的情况下转发给Swoole服务配置: ``` server{ listen:80; server_name www.swoole1.com; root /data/wwwroot/swoole; location / { if(!-e $request_filename){ proxy_pass http://127.0.0.1:9501 //Swoole服务 } } ``` Swoole的优势: 1. 拥有多个worker进程,由于worker进程是非阻塞的协程方式运行,所以一个worker进程能应对远大于PHP-FPM可以应对请求数量的高并发场景。 2. 常驻内存保持运行的能力带来的是不需要对每个请求进行初始化,减轻了初始化时创建对象的性能损耗。 3. 可以做TCP、UDP服务器。 Swoole的劣势: 1. 编码难度稍大。 2. 部分情况需要自行控制内存创建回收,防止内存溢出泄露。 ### 22.5 数据冷热 数据冷热处理是系统架构中的一环,是极其重要的。在高并发的场景中,数据冷热可以使系统拥有强大的性能和筛选处理的能力。简单来说就是:只处理冷数据,热数据直接响应。数据冷热处理就是我们期望将热数据存储在内存中,而将冷数据逐出到廉价的存储设备中,例如SSD、硬盘。 :-: ![](https://img.kancloud.cn/81/d6/81d657105ac35d568e79d57d4003aea1_700x228.png) #### 22.5.1 为什么做数据冷热 在生产环境中,随着数据、访问量增加,对服务器/数据库I/O性能是一种考验。在大多数情况下,常用数据库的数据存储在服务系统内存中,例如MySQL里InnoDB引擎的数据存储在InnoDB_buffer_pool。当需要获得数据时,在InnoDB_buffer_pool的数据会快速响应。但是,InnoDB_buffer_pool不是越大越好的,要考虑其整体能力,例如其数据大小、索引大小、查询获取成本、写入成本等。在很多情况下,MySQL的DML操作会造成MySQL性能下降,从而使得整个系统处理能力下降。在InnoDB_buffer_pool过大的情况下,查询能力也会下降。这时候,如果能区分冷热数据,将热数据查询直接返回响应,就避免了对数据库的查询操作,为其他操作腾出了处理空间。 ##### 所有需要DML操作通过数据库的缺点: - 访问量大时的DML会造成系统性能下降。 - 数据Buffer不能无限大且太大会造成DML操作性能下降。 - 随时随地的读线程将磁盘的数据读入Buffer,写线程将脏数据写入磁盘会造成数据库系统性能下降。 - 每次数据库I/O需要有连接、数据交换、释放的过程,占用服务系统和数据库系统资源导致性能下降。 - ....... 但是,数据应该落地数据库。也就是说,新数据、旧数据、修改的数据必须进入数据库系统,应该数据库做数据持久化基础。 ##### 使用数据冷热处理的优点 - 读取热数据不再需要再由系统服务和数据库服务间建立I/O。 - 快速读取,访问速度快。 - 服务系统、数据库系统节省资源保留能力,实现了部分高性能。 - ...... ##### 使用数据冷热的缺点 - 不能出错,否则后果严重。 - 难于维护。 #### 22.5.2 业务场景中的数据冷热 常见冷库:关系型数据库,数据保存在磁盘。 常见热库:Key-Value型数据库,数据保存在内存,并有数据持久化策略。 常见的作热库的软件在其内部都会设置逐出冷数据的策略、算法。对于它们来说何时逐出冷数据,一般看内存使用率是否超过设置的阈值,超过后则触发冷数据的逐出流程。例如Redis,有不同的策略清理其多余的数据。不过在某些策略中,会过多消耗CPU性能,比如给每一个热数据添加一个过期时间,过期自动清理。识别冷数据常见的方法是LRU,跟踪每个tuple或page的访问并记录访问频率,逐出时可以选择访问频率低于阈值的tuple或page进行逐出,也可以对访问频率做个排序,优先逐出访问频率最低的tuple或page。该方法的优点是对访问频率有个定量的分析,可以优先逐出访问频率最低的tuple或page,缺点是对每个tuple或page需要额外的内存开销来记录访问频率信息,内存占用较多,而且访问频率的记录是在访问的关键路径上,会造成rt的增加。 一般PHP、Java等语言开发的业务场景中不需要去做LRU和内存操作。所以可以探索其他方式。比如大多数可以作为热库的软件会对各种高级语言开放API。所以高级语言可以通过API,实现对热库数据控制。 最常见的方法就是,访问量大的数据写入冷库成功时,一并写入热库;如果写入冷库失败,立刻回滚数据,清空热库数据。 简单实例:当对访问量大的User表写入。(Redis热库、MySQL作冷库) ``` <?php ...... $redis = $app->redis; ...... $transaction = self::$db->beginTransaction(); try { $user = new \api\models\User(); $user->name = '张三'; $user->name = '13070000001'; $result = $user->save(); $transaction->commit(); if($result == true){ $redis->set('hot_user_'.$user->id,'time_' . time() . '_' . serialize($user)); } } catch (\Exception $exception) { $transaction->rollBack(); } ?> ``` 注意:在大多情况下,如果写入冷库失败时候需要删除热库数据($redis->del)。 简单实例:当对访问量大的User表查询。(Redis热库、MySQL作冷库) ``` <?php ...... $redis = $app->redis; ...... $userId = 10001; try { if ($redis->exists('user_' . $userId)) { $user = unserialize(substr($redis->get('user_' . $userId), 16)); } else { $user = User::findOne(10001); if ($user) { $redis->set('hot_user_' . $user->id, 'time_' . time() . '_' . serialize($user)); echo '读取时设置热数据成功'; } } ...... } catch (\Exception $exception) { echo '操作错误'; } ?> ``` 此时,如果可以命中热库数据,将直接读取热库数据。如果不能命中热库数据,将对冷库进行查询操作,并将冷库数据放入热库便于下次热库查询。 简单实例:设置定时任务清理热库冷数据。 ``` <?php var_dump('开始清理热库数据'); $redis = $app->redis; $hotKeys = $redis->keys("hot_*"); if(empty($hotKeys)){ var_dump('没有需要清理的数据'); die(); } $num = 0; $keys = []; foreach ($hotKeys as $item => $row) { $keys[] = $row; if (count($keys) >= 1000) { $hotDatas = $redis->mget(...$keys); $arr = []; foreach ($hotDatas as $key => $value) { $time = substr($value, 5, 10); if (($time + 10080) < time()) { $arr[] = $hotKeys[$key + $num]; } } if (!empty($arr)) { $redis->del(...$arr); } $num += 1000; $keys = []; } if (end($hotKeys) == $row) { $hotDatas = $redis->mget(...$keys); $arr = []; foreach ($hotDatas as $key => $value) { $time = substr($value, 5, 10); if (($time + 10080) < time()) { $arr[] = $hotKeys[$key + $num]; } } if (!empty($arr)) { $redis->del(...$arr); } } } var_dump('结束清理热库数据'); ?> ``` 可以设置定时任务在指定时间清理热库中过期的冷数据,逐出热库中的过期数据,保持内存的清洁。在很多情况下,可以为DML操作量大的表设置热库数据减轻冷库的负载。也可以创建热库字典。 定时任务:回顾12章Linux。 简单查询字典实例: ``` class HotData { //热库字典 public static $dictionary = [ self::HOT_B => 'hot_a_', self::HOT_B => 'hot_b_', ]; const HOT_A = 0; const HOT_B = 1; /** * 热数据查询方法 * @param $hotStr * @param $param * @param bool $isCache * @return mixed */ public static function getHotCache($hotStr, $isCache = false, $param = '') { $redis = \App->redis; if (empty($param)) { if ($redis->exists($hotStr)) { $data = unserialize(substr($redis->get($hotStr), 16)); if ($isCache && !empty($data)) { self::setHotCache($hotStr, $data); } } else { $data = ''; } } else { $heapHotStr = $hotStr . $param; if ($redis->exists($heapHotStr)) { $data = unserialize(substr($redis->get($heapHotStr), 16)); } else { switch ($hotStr) { //------user热库数据 case self::$dictionary[self::A]: $data = A::findOne($param); break; case self::$dictionary[self::B]: $data = B::findOne(['tel' => $param]); break; } } if ($isCache && !empty($data)) { self::setHotCache($heapHotStr, $data); } } return $data ?? ''; } /** * 热点数据缓存固定格式 * @param $hotStr * @param $data */ public static function setHotCache($hotStr, $data) { $redis = \App->redis; $redis->set($hotStr, 'time_' . time() . '_' . serialize($data)); } /** * 删除热点数据热库缓存 * @param $hotStr * @param $data */ public static function delHotCache($hotStr) { $redis = \App->redis; $redis->del($hotStr); } } ``` 有兴趣的朋友自行研究。 #### 总结 架构要按需设计。选择合适的硬件软件技术,以合适的代价得到高可用、高性能的系统。硬件软件不是固定的,架构设计的思路也不是固定的。还有许多软件技术可以作为架构设计探索的方向,比如Squid、kafka、hadoop、Apache、Dooker、Memcached、zookeeper、Dapper、Zipkin等。