批量加载关系对象和属性
Relation Load 的性能问题
长期以来,我们一直使用ORM技术进行数据库操作。相比直接用PDO写SQL并操作结果数组,利用ORM类有诸多好处,其中最好用的两点在于:
- 相比数组,Model类提供了一个编写getter和setter的地方,可以更灵活地处理和转化数据库中的字段。
- 利用关系加载技术让可以直接像访问普通属性一样访问相关的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属性,然后再载入school
的master
属性。
数组描述的是多级加载关系。['class', 'colleage']
数组等价于'class.colleage'
字符串。
有时会对某一级关系对象的多个下级存在加载需求,可以用关联数组实现。比如:
['school' => ['master', 'creator']]
表示关系载入school
之后,再载入school
的master
和creator
两个属性。
关联数组的key可以用点分隔表示多级,规则和字符串一样,key必需是数组中的最后一项。比如:
['school.master' => ['role', 'ext_info']]
表示关系载入school
后,再载入school
的master
属性,然后载入master
的role
和ext_info
属性。
这种写法等价于
['school', 'master' => ['role', 'ext_info']]
多层数组可以相互嵌套,比如:
[
'class.colleage' => [[
'creator' => [
'role',
'ext_info',
],
], [
'master' => 'role'
],
'location',
]
]
表示要载入class
属性,以及加载class
的colleage
属性,然后加载colleage
的creator
、master
、location
属性,还要加载creator
的role
和ext_info
属性,master
的role
属性。
用with批量关系加载的错误和异常
只有BelongToRelation
和HasOneRelation
实现了批量关系加载。如果声明对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
。