PHP高可用接口服务系统之如何优雅地实现复杂的接口

高级开发工程师似乎都拥有把复杂问题简单化的能力。接口服务系统的复杂性,在于它看不到、摸不着的领域业务规则和逻辑,海量数据下的存储体系设计,以及高并发请求访问下如何保证系统的可用性。

1 ADSM分层模式

传统的分层模式中,最为经典又最为流行的,莫过于MVC模式。然而,MVC模式也是滥用最为严重的,甚至脱离了其原作者的本意。此外,MVC适用于网站系统,却不适用于接口服务系统,因为在接口系统中没有视图层,同时又有其他特定的层级。经过多年的总结,在接口系统中,适宜采用ADSM分层模式。
ADSM分层模式灵感来自对MVC模式、领域驱动设计、架构模式。三种视角和接口服务系统这一领域的综合思考。其中,

    • A:表示Api,指接口控制层,负责接口参数的获取,对下层实例的创建与调用,以及接口结果的返回
    • D:表示Domain,指领域业务层,负责业务逻辑、领域规则、行业标准、数据处理等操作,蕴含着业务的价值
    • S:表示Service,指无状态的基础设施服务层,此概念来自领域驱动设计
    • M:表示Model,指广义上的数据模型层,可以进行持久化存储、高效缓存的使用,远程接口的对接和接入其他存储媒介

调用关系和依赖顺序,自顶而下,分别是:Api接口控制层、Domain领域业务层、Service基础设施服务层、Model广义数据模型层。每一层的分工明确,且能保持高度集中的关注点。例如,Api层可以把实现与调用分离,Api层只关注如何决策和调度,站在概念视角完成最高层的业务需求。Domain层可以把业务与技术分离,专注于特定领域业务规则的完整性表达与维护,专注于和领域专家共同完成极具价值的规则和逻辑开发。Service层则将可重用的组件、类库与具体的业务功能分离,从而最大化复用既有的模块和组件,例如短信发送、邮件发送、通知推送、日志服务等公共的基础设施,可以以一种“开箱即用”的姿态快速应用到某个类、某个业务场景和某个系统中。最后,最底层的是Model层,它只关注在技术层面如何提供和处理数据,遵循于CQRS模式,可以在Model层内部把查询的命令这两大操作分离,并站在非功能性需求的角度,权衡安全性、性能、兼容性、容错性、吞吐量等因素。

2 反模式:断层式调用

何谓断层式调用? 在分层架构里,按不同的职责或出于不同的关注点,会把代码划分为多个相对独立的逻辑层。如在领域驱动设计中,Evan把系统分为:表示层、应用层、领域层和基础设施层。
在这种情况下,通常分为高层和底层,且高级层依赖于更低的底层,但底层不依赖于高层。而且高层通常调用相依的底层,不会对更低的底层进行超级跨层调用。但在实际项目中,我经常会看到这种对底层超级跨层调用,所以我把它称为断层式调用

一个断层式调用的例子

以在PhalApi进行接口开发为例,PhalApi也采用了分层架构,即分为ADM三层,分别是:接口层(Api)、领域层(Domain)、模型层(Model)。其中,接口层相当于控制决策层,领域层是面向业务的逻辑处理层,模型层是面向技术的数据层,并且在PhalApi我们也强调了其调用顺序。
这里不难给出一个断层式调用的例子,也是很多同学会遇到的问题。

<?php
namespace Api;

use PhalApi\Api;
use App\Model\User as UserModel;

class User extends Api {

    public function getRules() {
        return array(
            'getUserInfo' => array(
                'userId' => array('name' => 'user_id', 'type' => 'int', 'desc' => '用户ID'),
            ),
        );
    }

    public function getUserInfo() {
        $model = new UserModel();
        $info = $model->getByUserId($this->userId);
        return $info;
    }
}

上面编写了一个User接口,并提供了根据用户ID获取获取用户信息的接口。但具体的实现却是在Api层直接调用了Model层,中间跳过了Domain层,所以这是一个断层式调用的例子。

断层式调用,好不好?

在小项目里,更多是需要进行快速开发,为了赶项目进度而寻找便捷途径,开发人员通常会进行断层式调用。这种做法,我觉得对于小项目来说,短期内是有效的。但从长远来说,以及对于大项目来说,出于标准化和规范性,以及项目技术债务的维护角度来说,我则觉得这是不可取的。
为什么不好呢?
继续以上面为例。可以看到,在Api中对Model层进行调用,实际上这个调用过程就是一个实现过程,它可能很简,也可能很复杂,可能是简单地根据某个参数获取数据即可,也有可能需要根据不同的条件组合处理多种情况。如果别的业务场景也需要同样的数据,那么在断层式调用的情况下,也就很容易导致了“复制-粘贴”编程。当这段调用过程因为业务需要发生变化时,其他调用的场景就需要重复相应更改。同样,如果是因为非功能性需要,在技术层面要在数据库获取前加一层高效缓存,就提升系统的吞吐性,那么也需要同样的大量重复调整。
举一些具体的场景来加深这块的理解。
最初,老板说,加一个业务需求。例如,老板发现有些恶意用户,所以提出了要屏蔽某些特定ID的恶意用户,对于这么一个业务需求,后面接手维护的开发人员撸起袖子干起来后,代码很可能变成了这样:在获取用户信息前添加了相应的检测和判断。

    public function getUserInfo() {
        $model = new UserModel();
        $info = !in_array($this->userId, array(/** 配置多个恶意用户ID */))
            ? $model->getByUserId($this->userId)
            : array();
        return $info;
    }

到目前,一切都运行得很了,项目上线后老板得到了想要的效果,你也得到了相应的认可。
然后,Team Leader说,做一下技术优化吧。随着项目越受欢迎,访问量越来越高,有一天你的Team Leader要求需要添加一层高效缓存,以免每次都穿透到数据库。所以,迭代后的代码很可能是这样:添加了一层缓存控制,如果有缓存就不再读取数据库。

    public function getUserInfo() {
        $model = new UserModel();

        $cacheKey = sprintf('user_info:%s', $this->userId);
        $info = DI()->cache->get($cacheKey);

        if (empty($info)) {
            $info = !in_array($this->userId, array(/** 配置多个恶意用户ID */))
                ? $model->getByUserId($this->userId)
                : array();

            DI()->cache->set($cacheKey, $info, 600);
        }

        return $info;
    }

非功能性的要求虽然也满足了,但代码,却变得越来越庞大。更糟糕的是,关注点越来越混乱,从最高层的概念视角:获取用户信息,到业务规则:过滤恶意用户,再到技术需求:添加高效缓存,这三个不同层次的关注点都混淆在一起。这必将加大后续维护的成本,因为新来的开发人员需要理解这些代码,需要去猜想以前发生了什么,以及这些代码是做什么的。
不得不提醒一下的是,这些混乱的代码,随着重复实现的次数越大,项目就会变得越加难以维护。
“计算机的任何问题,都可以引入一个中间层来解决。”我的建议则是,按当前所在的分层架构规定设计好的层级进行依次顺序调用,不要进行断层式调用。如果没有,则补充相应的缺失层,再进行调用。很可能会有同学反对,这样完全没必要。一是因为多一层代码封装会增加开发量,还有就是多一层也会增加对系统的性能影响。但现在计算机速度已越来越快,添加多这一层,不会造成什么性能上的影响。而对于所增加的开发量,是需要与规范性、项目维护成本进行权衡的,但我认为总的来说,它是值得的。毕竟,既然有明确的分层,就应该尽可能遵循它。
再回到上面的例子,应该把三种不同的关注点,分别按PhalApi所设计的分层架构划分到不同的层去。最终调整后,代码如下所示。
在Api层,关注概念完整性,获取用户信息。

    public function getUserInfo() {
        $domain = new UserDomain();
        return $domain->getUserInfo($this->userId);         
    }

在Domain层,关注业务规则,过滤恶意用户。

<?php
namespace Domain;

class User {
    public function getUserInfo($userId) {
        $model = new UserModel();
        $info = !in_array($this->userId, array(/** 配置多个恶意用户ID */))
            ? $model->getByUserId($userId)
            : array();
        return $info;
    }
}

在Model层,关注非功能性,提供用户数据,不管内部实现是使用了高速绊缓存还是关系型数据库。

<?php
namespace App\Model;

class User extends \PhalApi\Model\NotORMModel {
    public function getUserInfo($userId) {
        $cacheKey = sprintf('user_info:%s', $userId);
        $info = DI()->cache->get($cacheKey);

        if (empty($info)) {
            $info = $this->getORM()->get($userId);
            DI()->cache->set($cacheKey, $info, 600);
        }

        return $info;
    }
}

反模式已经有很多,我不知这种情况算不算也是一种反模式。但我觉得即使它现在不是,可能在将来也会成为反模式之一,只是相比其他反模式,其恶化程度没那么严重。不管怎么说,遵循规范总是有好处的,尤其是在大型项目和从长远上考虑来看,我觉得。

3 再谈数据库操作

记一次20秒慢查询的优化经历

以下SQL优化经历,我曾经在我的博客上分享过,但作为一个有趣的秩事,这里再简单分享一下,顺便以此小故事作为引子,谈谈我对数据库操作、使用与优化的一些见解。
在2015年4月份,当时正在项目发布上线的过程中,我们发现有一个页面无法正确获取数据,经排查原来是系统中在请求某个新增的接口服务超时了,而最后定位到的原因是因为某条数据库的SQL查询语句执行时间长达到20多秒,从而导致了问题的发生。在那次问题里,虽然没有过多高深的理论或技术,我还是备忘了一下此次的经历,同时为技术新同学解读一些思想误区。
因为涉及商业的敏感信息,这里不对业务功能进行过多的描述,为了突出问题所在,我会用类比的语句来描述当时的场景。当时复杂的SQL语句可以表达如下:

SELECT * FROM a_table AS a 
LEFT JOIN b_table AS b ON a.id=b.id 
WHERE a.id IN (
    SELECT DISTINCT id FROM a_table 
    WHERE user_id IN (100,102,103) GROUP BY user_id HAVING count(id) > 3
)

从上面简化的SQL语句,可以看出,首先进行的是关联查询。其次,是嵌套的子查询。此子查询是为了找出多个用户共同拥有的组ID。所以语句中的“100,102,103”是根据场景来定的,并且需要和后面“count(id) > 3”的个数对应。简单来说,就是找用户交集的组ID。
那么耗时在哪里呢?假设现在a_table表的数据量为20W,而b_table的数据量为2000W。读者可以想一下,你觉得主要的耗时是在关联查询部分,还是在子查询部分呢?
数据库的底层原理,以及相应的数学模型、算法,是很值得深入研究的。暂时抛开大学所学的理论知识,我们可以通过类比和简单的测试来验证是哪一块环节出了问题。
首先,对于只有一个用户ID时,上面的语句简化成:
SELECT * FROM a_table AS a
LEFT JOIN b_table AS b ON a.id=b.id
WHERE user_id IN (100)

这条SQL语句执行速度是很快的。因此,初步断定应该是嵌套的子查询部分占用了大部分的时间。既然定位到了是嵌套的子查询语句的问题,那又要分为两块待排查的区域:是子查询本身耗时大,还是嵌套而导致慢查询?结果很容易发现,当我把子查询单独在DB中执行时,是非常快的,继续排除。剩下的就不言而喻了,20秒的慢查询是嵌套引起的。
因为当时正处于上线紧急的过程中,面临的压力很大,为了能充足的把握解决这次慢查询,我快速地验证了我初步的结论:

    • 1、将子查询的ID单独执行,并把得到的结果序列手动拼成一段ID,如:1,2,3,4, … , 999
    • 2、将上面得到的序列ID,手动替换到原来的SQL语句
    • 3、执行,发现,很快!只用了约150 ms

就这样,自己给自己说了一声:Well Done! 然后修复上线。对于线上的问题,大部分时间都是消耗在问题定位和原因分析,以及推演反原整个案情的发生,逻辑要通顺,并且能用充分的日志、数据、实验、理论来证明和支撑。一旦问题找到了,原因也就找到了,解决方案不言而喻,最后“对症下药”,代码相应处理即可。
当前,实际的SQL语句,会比这个更为复杂,但已足以表达问题所在。但在解决这个慢查询之前,我还做了另外一个假设和小实验。表b_table比表a_table的数量要多,若将 b_table 左关联 a_table ,则很慢,大概是1秒左右,而且表a_table的数据量是很少的;但若反过来,a_table 左关联 b_table 的疾,则很快,大概是100毫秒。所以我又发现了一个有趣的现象:大表 左关联 小表,很慢;小表 左关联 大表,很快。
当然,这些现象背后的理论我们都学习过,也知道,但在实际开发时会容易忘却或无意识忽略。又或者一开始两个表都为空时没有任何数据时,问题是不会突显的。若缺少经验而没考虑到后期这两个表增长的速度,日后就会容易埋下一个风险点,随着表数据的不断累积,越发明显。这就是另外一个需要注意的点。
实际上,我一开始使用嵌套子查询,是存在这样一个思维误区:我觉得将这些操作交给MySQL来处理会更为高效,毕竟数据库内部会有良好的机制来执行这些查询语句。然后,实际上,我错了。因为这并不是简单的合并和批量查询。当我们决定使用一些底层的技术、系统、组件或者工具时,只有当我们理解透彻了,才能更恰当地使用。如果理解不充分,或者不加思考就使用,或断定工具、框架、底层无所不能时,往往会误用。

分库分表设计

讲到数据库,有一个不得不提的主题就是数据库的分库分表设计。
在我任职的第一家公司,其内部研发的框架中,有一套堪称完美的分库分表设计。这套机制,不仅能通过简单的配置完成数据库分库分表的设计,还能自动生成Model层的PHP代码,还能在数据库发生迁移时自动切换,甚至还能快速配置多级缓存体系,存储海量数据。
但并不是每个项目都那么幸运。在这之后,不管是在上市公司,还是在中型高速发展的企业,我都和广大技术开发人员共同经历了后期才进行人工分库分表而带来的巨大风险和高昂的维护成本。对于原来只有一张表的情况,现在要分拆到多个表,甚至多个数据库,不仅需要在运维层面进行数据库的迁移和同步,还需要在代码上调整全部与之相关的表名、数据库连接配置、关联和嵌套查询等。即便选择是在凌晨时分进行迁移切换操作,也难免能做到在100%不信服的前提下100%顺利完成这一过程。
那么,分库分表,到底难不难呢?如果不难,又该怎么设计?分库分表在接口服务系统中是必须的吗?除了在业务代码进行分库分表外,有没其他替代解决方案?
分库分表,难度适中,但要考虑的因素较多,要综合权衡。首先,要着重解决业务层技术开发人员如何使用分库分表的问题。也就是说,开发人员怎么指定分库分表,是有感的还是无感的?我建议是尽量简化分库分表的使用,并且具体的分库分表策略应该是对客户端透明的,可以遵循“惯例优于配置,配置优于实现”这一原则。其次,要考虑特定数据库表与存储配置、数据库连接之间的映射关系,以及各类资源的管理和维护。至于分库分表在接口服务系统是否必须的,要视情况而定。当一个数据库表的纪录条目数据高达千万级别以上时,就要开始考虑分库分表了。尤其在以数据流和信息流为主的接口服务系统中,更要提前加以考虑和规划,把事情做在前面,才不至于总是被动应对仓促的调整和突发的故障。除了在业务代码进行分库分表外,也可以使用数据库本身在分布式存储方面提供的分片存储技术。

8.3.4 如何防并发

在有些项目中,接口可以分为上行接口和下行接口。我们也可以换另一种同等的分类,即根据CQRS模式,分为查询接口和命令接口。查询接口是幂等接口,不管是请求一次,两次还是千万次,其结果都是一样的。命令操作是会产生副作用的接口,它也许会修改实体状态,或许会进行一些创建或删除工作,甚至可能还会有事务型的操作。通过这样分解,关于接口系统如何防并发这一话题,就自然可以继续分解为:针对查询接口如何防并发,以及针对命令接口如何防并发。
针对查询接口,可以使用高效缓存对重复的结果数据进行缓存,减轻接口请求对持久化存储的数据库服务器的流量冲击。但要注意在流量巨大情况下的hot key单点问题和因穿透而引起的雪崩情况。对于前者,可以采用缓存冗余的做法,通过数据副本分散单点的压力;对于后者,可参考前面所介绍的多层数据缓存体系以及数据模板,综合多层缓存的优势,再结合锁机制,进行巧妙的设计。
针对命令接口,因为涉及更新、添加、删除、通知、事务等操作,不能使用缓存对结果进行缓存,但是可以结合异步定时任务作为辅助手段,先将高并发的请求,序列化存入高效队列中,然后再定时触发及时消费。计算机的任何问题,都可以通过添加一个中间层来解决。由此引发的新问题,是要注意队列消费的时效性和数据最终一致性,以及队列丢失的异常情况。
归根到底,有效应对高并发的策略是采用分布式集群。例如,一开始,只使用了一台服务器就完成了全部操作、存储和任务,集Web服务器、数据库服务器、缓存服务器、队列服务器于一身。接着,对于每一类服务器类型进行拆分,我们有了专门的服务器执行PHP代码,有独立的服务器存储数据库,并且也有了单独的缓存服务器运行着Redis或者Memcache。当系统的流量越来越大时,在硬件方面就可以进行水平扩容。对于数据库,配置主数据库和从数据库,主库用于写入,从库则用于读取,甚至可配置一主多从。而针对Web服务器和缓存服务器,更是可以从单机升级到集群。从起点到终点再回到起点,在整个链路把流量分散到各个服务器,方能应对海量数据下的高并发请求。

发表评论