批量加载关系对象和属性

Relation Load 的性能问题

长期以来,我们一直使用ORM技术进行数据库操作。相比直接用PDO写SQL并操作结果数组,利用ORM类有诸多好处,其中最好用的两点在于:

  1. 相比数组,Model类提供了一个编写getter和setter的地方,可以更灵活地处理和转化数据库中的字段。
  2. 利用关系加载技术让可以直接像访问普通属性一样访问相关的Model对象,无需再编写相应SQL。

然而一直以来,上述两种常见的ORM用法,存在严重的性能问题。因为getter和自动关系加载都针对单个对象操作,ORM库常常会为一页的记录生成上百条SQL查询语句,执行起来耗时较长。

比如要查询以下报表:

学员姓名 手机号 所属分校 所在高校 所在班级 负责老师 最近沟通

报表中的学员姓名、手机号来自student表,所属分校来自school表,所在班级来自class表,所在高校来自colleage表,负责老师来自teacher表,最近沟通来自contact_latest表。表关系如下:

-----------
| teacher |
-----------
     ^
     |
-----------
| school  |
-----------
     ^
     |
-----------     ------------------
| student | --> | contact_latest |
-----------     ------------------
     |
     V
-----------
|  class  |
-----------
     |
     V
------------
| colleage |
------------

每查询一页的报表数据,假设一页50条记录,需要生成两条select student的SQL(一条count一条分页选取,在之后的关系加载过程中,需要再生成250条select语句,用于为每条student记录查询其它相关记录,虽然每条关联记录都是用主键id查询。

用with方法提升性能

引发上述性能问题的关键原因在于,每个ORM记录对象单独做关系加载时,并不知道其它对象也要做相似的关系加载,所以只能生成大量的单条查询SQL。这样在和数据库通讯RTT值高的情况下,性能下降尤其明显。

为了解决这种性能问题,x2ts的从2.5版开始,向Model类新增了一个with方法,它允许我们定义一系列需要批量载入的关系属性,并在下一次many或者sql调用时生效。比如:

X::model('student')->with(
    'school.master',
    'contact_latest',
    ['class', 'colleage']
)->many('user_id IS NOT NULL', [], 0, 50);

上面这段代码会在载入50条学员记录之后,自动载入和它们关联的分校、老师、班级、高校、联系记录。总共生成6条SQL语句,除student外的5条关联查询SQL,都使用IN查询取得集合数据,有效避免了大量单条查询语句的生成。

with方法接收一个或者多个参数,每个参数是一条关联查询定义,可以是字符串、也可以是数组,还可以是x2ts\db\orm\BatchLoader接口的实例。

with方法的参数说明

字符串描述的是关联关系的名称,可以用点分隔做次级加载。比如:

'school.master'

表示关系载入school属性,然后再载入schoolmaster属性。


数组描述的是多级加载关系。['class', 'colleage']数组等价于'class.colleage'字符串。


有时会对某一级关系对象的多个下级存在加载需求,可以用关联数组实现。比如:

['school' => ['master', 'creator']]

表示关系载入school之后,再载入schoolmastercreator两个属性。


关联数组的key可以用点分隔表示多级,规则和字符串一样,key必需是数组中的最后一项。比如:

['school.master' => ['role', 'ext_info']]

表示关系载入school后,再载入schoolmaster属性,然后载入masterroleext_info属性。

这种写法等价于

['school', 'master' => ['role', 'ext_info']]

多层数组可以相互嵌套,比如:

[
    'class.colleage' => [[
            'creator' => [
                'role',
                'ext_info',
            ],
        ], [
            'master' => 'role'
        ],
        'location',
    ]
]

表示要载入class属性,以及加载classcolleage属性,然后加载colleagecreatormasterlocation属性,还要加载creatorroleext_info属性,masterrole属性。

用with批量关系加载的错误和异常

只有BelongToRelationHasOneRelation实现了批量关系加载。如果声明对HasManyRelation或者ManyManyRelation作批量关系加载,会抛出MethodNotImplementException异常。

指定关系加载时的名称应当是关系名称,如果在Model的relations数组中没有定义该名称,则会抛出UnresolvableRelationException

自定义批量加载器

前面的例子全部都在进行关系加载,有的时候数据表上没有建立外键关系,但我们通过自己写的getter实现单条数据的载入,也面临类似的性能问题。比如:

class QuestionFilter extends x2ts\db\orm\Model {
    public function getSubject() {
        return X::model('subject')->load($this->subject_id);
    }
}

如果我们查询出QuestionFilter的列表,然后需要对每条记录单独查询所属学科。从而产生大量select语句,性能不佳。

可以通过自定义批量加载器实现关联关系的批量加载。

// 重新定义getter和setter,允许写入subject属性
class QuestionFilter extends x2ts\db\orm\Model {
    private $subject;
    public function getSubject() {
        return $this->subject ?? 
            X::model('subject')->load($this->subject_id);
    }

    public function setSubject($s) {
        if ($s instanceof Subject) {
            $this->subject = $S;
        }
    }
}

//实现一个自定义的批量加载器
class QFSubjectLoader implements x2ts\db\orm\BatchLoader {
    public function name():string {
        return 'subject';
    }

    /**
     * @param Model[] $models
     * @param array $subLoaders
     *
     * @return void
     */
    public function batchLoadFor($models, $subLoaders) {
        $subjectIds = [];
        foreach ($models as $model) {
            $subjectIds[] = $model->subject_id;
        }
        $inStr = implode(',', $subjectIds);
        $subjects = X::model('subject')
            ->with(...$subLoaders)
            ->many("id IN ($inStr)");
        $subjectMap = [];
        foreach ($subjects as $subject) {
            $subjectMap[$subject->id] = $subject;
        }
        foreach($models as $model) {
            $model->subject = $subjectMap[$model->subject_id] ?? null;
        }
    }
}

//使用自定义的批量加载器
$filters = X::model('question_filter')
    ->with(new QFSubjectLoader())
    ->many('user_id=:uid', [':uid' => $currentUser->id], 0, 50);

批量加载器的name方法定义了加载器的属性名,batchLoadFor方法负责批量载入相关数据,并写入到$models各元素中,$subLoaders需要透传到当前属性Model的with方法里,实现次级批量加载。

如果自定义的批量加载器不支持次级批量加载,或者当前载入的不是Model对象,则应该忽略$subLoaders,如果$subLoaders不为空,应输出warning

使用ReferenceBatchLoader加载器

上面介绍的学科可以用自定义加载器载入,然而这种没有外键却需要加载相关Model对象的情况非常常见,所以x2ts框架提供了一个ReferenceBatchLoader加载器,以解决这种使用场景。

上例中的QFSubjectLoader可以不必自己开发,直接用ReferenceBatchLoader

$filters = X::model('question_filter')
    ->with(new ReferenceBatchLoader(
        'subject',
        'subject_id',
        X::model('subject')
    ))
    ->many('user_id=:uid', [':uid' => $currentUser->id], 0, 50);

ReferenceBatchLoader的构造函数接收三个参数:第一个是加载器的名称,也是加载进来之后的属性名,需要自己为该属性写好getter和setter,或者可以直接声明public的属性;第二个参数是当前Model对象中保存外部对象的主键的属性名;第三个参数是要加载的外部Model的一个对象实例。

如果当前对象的外部引用字段为空,或者引用的id在数据库里找不到,批量加载器会给相应属性赋值为null

标签: x2ts, orm, with

添加新评论