记一次Laravel中with()方法事故

之前有一段代码使用了Laravel模型关联的预加载with()方法,代码类似如下

1
2
3
$masters = Master::with(['servant' => function($query) {
$query->select('id', 'master_id', 'name', 'level')->orderBy('level', 'DESC')->first();
}])->get();

结果类似如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
array:2 [▼
0 => array:8 [▼
"id" => 1
...
"servant" => array:1 [▼
0 => array:4 [▼]
]
]
1 => array:8 [▼
"id" => 2
...
"servant" => []
]
]

造成了某些结果的servant属性对应的是空,但事实上他们在数据库中是有对应关联数据的,然后造成了一个bug. 今天重新看了下 with() 的源码感觉终于知道了原因


创建测试数据

为了复现上面的问题 现在建2个表来模拟下,首先创建 master 当作主表,然后创建 servant 作为子表,他们之间的对应关系是 一对多 即一个 master 有多个 servant。另外我没有建立主外键关系,大概知道就好了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 创建主表
CREATE TABLE `master` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(45) NOT NULL,
`age` INT NOT NULL,
`sex` TINYINT(1) NOT NULL DEFAULT 1,
`level` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` INT NOT NULL,
`updated_at` INT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `id_UNIQUE` (`id` ASC));
# 添加测试数据
insert into `master` (`id`, `name`, `age`, `sex`, `level`, `created_at`, `updated_at`) values('1','纪晓岚','48','1','7','1548231053','1548231053');
insert into `master` (`id`, `name`, `age`, `sex`, `level`, `created_at`, `updated_at`) values('2','和珅','50','1','7','1548231056','1548231056');
# 创建子表
CREATE TABLE `servant` (
`id` INT NOT NULL AUTO_INCREMENT,
`master_id` INT NOT NULL,
`name` VARCHAR(45) NOT NULL,
`age` INT NOT NULL,
`sex` TINYINT(1) NOT NULL DEFAULT 1,
`level` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` INT NOT NULL,
`updated_at` INT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `id_UNIQUE` (`id` ASC));
# 添加测试数据
insert into `servant` (`id`, `master_id`, `name`, `age`, `sex`, `level`, `created_at`, `updated_at`) values('1','1','杀手A','18','1','6','1548231055','1548231055');
insert into `servant` (`id`, `master_id`, `name`, `age`, `sex`, `level`, `created_at`, `updated_at`) values('2','1','杀手B','17','2','7','1548231054','1548231054');
insert into `servant` (`id`, `master_id`, `name`, `age`, `sex`, `level`, `created_at`, `updated_at`) values('3','1','杀手C','28','1','5','1548231050','1548231050');
insert into `servant` (`id`, `master_id`, `name`, `age`, `sex`, `level`, `created_at`, `updated_at`) values('4','2','刺客1','23','1','7','1548231051','1548231051');
insert into `servant` (`id`, `master_id`, `name`, `age`, `sex`, `level`, `created_at`, `updated_at`) values('5','2','刺客2','19','2','6','1548231052','1548231052');

正常的对应关系如下 纪晓岚有3个从者 和珅有2个从者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 获取全部数据
$masters = Master::with('servant')->get();
dd($masters->toArray());
# 结果
array:2 [▼
0 => array:3 [▼
"id" => 1
"name" => "纪晓岚"
"servant" => array:3 [▶]
]
1 => array:3 [▼
"id" => 2
"name" => "和珅"
"servant" => array:2 [▶]
]
]

下面来看源码,一步步找到出现问题的原因

一对多关联

Laravel中实现一对多关联关系可以通过使用 hasMany() 方法,所以在Master的Model中我加入了

1
2
3
4
public function servant()
{
return $this->hasMany('App\Models\Servant', 'master_id', 'id');
}

看下源码中 在 \vendor\laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasRelationships.php 文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* Define a one-to-many relationship.
*
* @param string $related
* @param string $foreignKey
* @param string $localKey
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function hasMany($related, $foreignKey = null, $localKey = null)
{
// 实例化传进来的关联的model
$instance = $this->newRelatedInstance($related);
// 获取子表中关联的字段
$foreignKey = $foreignKey ?: $this->getForeignKey();
// 获取主表中关联的字段
$localKey = $localKey ?: $this->getKeyName();
// 返回 \Illuminate\Database\Eloquent\Relations\HasMany 实例化后的对象
return $this->newHasMany(
$instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey
);
}
/**
* Instantiate a new HasMany relationship.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \Illuminate\Database\Eloquent\Model $parent
* @param string $foreignKey
* @param string $localKey
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
protected function newHasMany(Builder $query, Model $parent, $foreignKey, $localKey)
{
return new HasMany($query, $parent, $foreignKey, $localKey);
}

其创建过程就是一些设置 父模型、子模型、关联字段、关联约束等

预加载 with() 方法

Laravel提供了预加载的功能 这样可以避免出现重复查询 并且可以把查询降低到2次

看下 with() 方法的源码, 在文件 \vendor\laravel\framework\src\Illuminate\Database\Eloquent\Model.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Begin querying a model with eager loading.
*
* @param array|string $relations
* @return \Illuminate\Database\Eloquent\Builder|static
*/
public static function with($relations)
{
// 先获取一个当前模型表的查询 \Illuminate\Database\Eloquent\Builder 对象
// 然后执行 with() 方法 参数是一个字符串或者数组 对应上面的测试就是servant关联方法的方法名字了
return (new static)->newQuery()->with(
is_string($relations) ? func_get_args() : $relations
);
}

然后看下 Builder 下的 with() 方法 在文件 \vendor\laravel\framework\src\Illuminate\Database\Eloquent\Builder.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Set the relationships that should be eager loaded.
*
* @param mixed $relations
* @return $this
*/
public function with($relations)
{
// 判断传递进来的参数是字符串还是数组,如果是字符串就或取包含函数参数的列表 这里会是一个数组 形如:['servant'] 如果是数组就直接传递进去
// 然后执行 parseWithRelations() 方法
$eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations);
// 把返回的关联关系数组重新合并赋值给了 $eagerLoad 数组中
$this->eagerLoad = array_merge($this->eagerLoad, $eagerLoad);
return $this;
}

下面看下 parseWithRelations() 方法 还是和上面同一个文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 将一系列关联关系解析为单个
* Parse a list of relations into individuals.
*
* @param array $relations
* @return array
*/
protected function parseWithRelations(array $relations)
{
$results = [];
foreach ($relations as $name => $constraints) {
// If the "relation" value is actually a numeric key, we can assume that no
// constraints have been specified for the eager load and we'll just put
// an empty Closure with the loader so that we can treat all the same.
// 大概就是说如果 key 是一个数字的话就给他设置一个空的闭包
if (is_numeric($name)) {
// 把name 设置 成了constraints 对应测试例子 此时 $name = 'servant';
$name = $constraints;
// 然后name里没有: 就设置了一个空闭包 此时 是
// name = servant; constraints = 闭包
list($name, $constraints) = Str::contains($name, ':')
? $this->createSelectWithConstraint($name)
: [$name, function () {
//
}];
}
// We need to separate out any nested includes. Which allows the developers
// to load deep relationships using "dots" without stating each level of
// the relationship with its own key in the array of eager load names.
// 大概就是说 给 类似 with('servant.power') 这种嵌套的关联预加载上约束条件
$results = $this->addNestedWiths($name, $results);
$results[$name] = $constraints;
}
// 这里返回的按照上面例子应该是
// [
// 'servant' => function() {}
// ];
return $results;
}

所以在执行完 Master::with('servant') 后 在Builder的with() 方法中设置了一个 eagerLoad 属性 其值为

1
2
3
$eagarLoad = [
'servant' => function() {}
];

然后返回了该 Builder 对象

get() 方法

在看下最后执行的 get() 方法 还是在上面的同一个文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* Execute the query as a "select" statement.
* 将查询作为 select 语句执行
* @param array $columns
* @return \Illuminate\Database\Eloquent\Collection|static[]
*/
public function get($columns = ['*'])
{
$builder = $this->applyScopes();
// If we actually found models we will also eager load any relationships that
// have been specified as needing to be eager loaded, which will solve the
// n+1 query issue for the developers to avoid running a lot of queries.
// 主要就是讲为了避免 n+1 查询问题 会去加载需要预加载的关联模型
// 下面的count 里是 \Illuminate\Database\Eloquent\Model[] 的模型数组 基本命中了都会大于0
// 然后执行 eagerLoadRelations() 方法
if (count($models = $builder->getModels($columns)) > 0) {
$models = $builder->eagerLoadRelations($models);
}
return $builder->getModel()->newCollection($models);
}

下面看 eagerLoadRelations() 方法 还是同一个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Eager load the relationships for the models.
*
* @param array $models
* @return array
*/
public function eagerLoadRelations(array $models)
{
// 从上面的测试中 此时 $this->eagerLoad 应该是 ['servant' => function() {}]
foreach ($this->eagerLoad as $name => $constraints) {
// For nested eager loads we'll skip loading them here and they will be set as an
// eager load on the query to retrieve the relation so that they will be eager
// loaded on that query, because that is where they get hydrated as models.
// 如果key 里没有 . 就去执行 eagerLoadRelation() 方法
if (strpos($name, '.') === false) {
$models = $this->eagerLoadRelation($models, $name, $constraints);
}
}
return $models;
}

下面看 eagerLoadRelation() 方法的实现 还是同一个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/**
* Eagerly load the relationship on a set of models.
*
* @param array $models
* @param string $name
* @param \Closure $constraints
* @return array
*/
protected function eagerLoadRelation(array $models, $name, Closure $constraints)
{
// First we will "back up" the existing where conditions on the query so we can
// add our eager constraints. Then we will merge the wheres that were on the
// query back to it in order that any where conditions might be specified.
// 获取关联关系对应的方法 也就是 servant()了
$relation = $this->getRelation($name);
// 给关联关系设置约束 传递进去的是查询全部数据的 \Illuminate\Database\Eloquent\Collection 对象数组 也就是全部数据了
// 这是一个抽象方法需要各自去实现
$relation->addEagerConstraints($models);
// 放到闭包执行 然后原来的空闭包就变成了类似下面这样
// function(Relation $query) {
//
// };
$constraints($relation);
// Once we have the results, we just match those back up to their parent models
// using the relationship instance. Then we just return the finished arrays
// of models which have been eagerly hydrated and are readied for return.
// 大概就是说一旦获取到结果 我们可以和父模型进行匹配 把对应的结果设置到关联中 然后返回完整的模型数组
// 在简单点就是把关联匹配的数据分别根据主表关联的字段(比如id)然后对应设置到主表Collection对象中关联的key上 也就是上面的servant
return $relation->match(
$relation->initRelation($models, $name),
$relation->getEager(), $name
);
}
// 然后是一对多关联 hasMany()方法最终继承自 HasOneOrMany 类 在文件 `\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Relations\HasOneOrMany.php` 看下它的实现
/**
* Set the constraints for an eager load of the relation.
*
* @param array $models
* @return void
*/
public function addEagerConstraints(array $models)
{
// 就是使用了一个wherein查询 查询的值是从上面传递进来的所有数据的集合中取出关联的lcoalKey(也就是设置hasMany中的localKey 所以是id)
// 对应本例子就是 wherein('master_id', [1, 2]);
$this->query->whereIn(
$this->foreignKey, $this->getKeys($models, $this->localKey)
);
}
// 上面的match()方法最终执行到了这里
/**
* Match the eagerly loaded results to their many parents.
*
* @param array $models
* @param \Illuminate\Database\Eloquent\Collection $results
* @param string $relation
* @param string $type
* @return array
*/
protected function matchOneOrMany(array $models, Collection $results, $relation, $type)
{
$dictionary = $this->buildDictionary($results);
// Once we have the dictionary we can simply spin through the parent models to
// link them up with their children using the keyed dictionary to make the
// matching very convenient and easy work. Then we'll just return them.
foreach ($models as $model) {
if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) {
$model->setRelation(
$relation, $this->getRelationValue($dictionary, $key, $type)
);
}
}
return $models;
}

好了一通设置后最终返回了 \Illuminate\Database\Eloquent\Collection 对象数组 这个很熟悉了吧 走到这里每个master下都设置上了一个名为 servant 的属性其值对应的是 Collection(Servant),所以后续的操作比如直接取出关联子表中的属性其实都是从这里直接取出,并不会去查询数据库了

预加载SQL执行过程

讲完了底层实现,下面看下执行过程中到底都执行了什么SQL。为了方便看结果 直接使用 Laravel-debugbar 来监测

1.加载全部

1
2
3
$masters = Master::with('servant')->get();
// select * from `master`
// select * from `servant` where `servant`.`master_id` in (1, 2)

2.给关联增加条件

1
2
3
4
5
$masters = Master::with(['servant' => function($query) {
$query->select('id', 'master_id', 'name', 'level')->orderBy('level', 'DESC');
}])->get();
// select * from `master`
// select `id`, `master_id`, `name`, `level` from `servant` where `servant`.`master_id` in (1, 2) order by `level` desc

其实增加条件过滤、筛选字段过滤、排序等都不影响。其最终只会执行2条SQL

3.给关联设置first()

1
2
3
4
5
6
$masters = Master::with(['servant' => function($query) {
$query->select('id', 'master_id', 'name', 'level')->orderBy('level', 'ASC')->first();
}])->get();
// select * from `mi_master`
// select `id`, `master_id`, `name`, `level` from `mi_servant` where `mi_servant`.`master_id` in (1, 2) order by `level` asc limit 1
// select `id`, `master_id`, `name`, `level` from `mi_servant` where `mi_servant`.`master_id` in (1, 2) order by `level` asc limit 1

这个时候他查询的都是同一条SQL。因为根据源码分析 他最后的关联是使用了 wherein(关联字段, [所有主表关联主键的数组]) 这种形式来设置约束条件 所有执行的都是同一条SQL。(PS:上面为什么是in(1, 2) 因为我只有2条测试数据,关联主键分别是1 和 2 如果你有3条记录主键分别是 5 6 7 那他就是in(5, 6, 7)了)。

正常来说会在执行 get() 的时候发生2条SQL查询 一条是查询全部的 一条是根据全部的id查询匹配条件的,总共2条SQL

为什么会变成3条SQL呢,因为在 eagerLoadRelation()$constraints($relation); 这里把同一个 Relation 传进了闭包 然后在外面执行 $query->select('id', 'master_id', 'name', 'level')->orderBy('level', 'ASC')->first(); 会多一次查询出来 查询内容是一样的。

然后 first() 会使用 limit 1 来过滤结果 所以最终查询到的子结果只有一条,然后在给Master设置属性的时候会有一个关联条件的匹配,而限制了只有一条基本只会命中一个Master的ID了,所以造成了最终的结果中 有些有关联属性 有些却为空

解决办法

一种就是先给主表查询全部(或者按指定条件查询主表),然后在用到的子表数据的时候去动态属性的形式来加载出子表的数据

1
2
3
4
5
6
$masters = Master::get();
foreach ($masters as $key => $val) {
$servant = $val->servant()->select('id', 'master_id', 'name', 'level')->orderBy('level', 'DESC')->first();
dd($servant->toArray());
}

不过这样会造成一个问题就是每天子表数据取出来都会有一次查询产生

第二种办法就是查询的时候只按需要条件过滤 然后在取数据的时候自己去获取需要的数据 如下

1
2
3
4
5
6
7
$masters = Master::with(['servant' => function($query) {
$query->select('id', 'master_id', 'name', 'level')->orderBy('level', 'DESC');
}])->get();
foreach ($masters as $key => $val) {
// ...
}

-------------The End-------------