先说一个查了很久的问题。
项目里有个用户列表页,一次展示 100 条记录,每条只显示头像和昵称。但那个页面加载特别慢,慢到需要 1.8 秒。SQL 日志打开一看,每条记录都在查完整的用户表——包括签名、地址、注册来源、行为标签这些字段,加起来十几个。
真正用到的只有两个。剩下那些字段查出来,序列化进数组,塞进模板,然后在模板里被扔掉。
当时的处理是在 Repository 里加一个 select(),只挑需要的列。改完确实快了,但代价是所有调用方都得知道”我要哪几个字段”。有人调 findForList(),有人调 findForDetail(),方法名越来越长,方法数量也越来越多。三个月之后这个 Repository 长到了六百多行。
问题的本质其实不在查询优化,而在于对象一旦被构造,它的所有字段就必须已经是可用的。你没法表达”这个对象现在只有 id 和昵称,其他字段等你真要用的时候再查”。
PHP 8.4 引入的惰性对象(Lazy Objects)就是来解决这件事的。
一、以前是怎么做懒加载的
在惰性对象出现之前,PHP 里做懒加载基本只有一条路:魔术方法。
class User
{
private ?array $data = null;
public function __get(string $name): mixed
{
if ($this->data === null) {
$this->data = $this->repo->fetchFull($this->id);
}
return $this->data[$name] ?? null;
}
}
这套写法能用,但问题一串。属性不再是真实属性,IDE 补全失效,静态分析工具识别不出来,json_encode 拿到的是空对象,isset() 的行为也变得反直觉。而且它只能覆盖”不存在的属性”,如果类里已经声明了真实属性,__get 根本不会被调用。
Doctrine 这类 ORM 用的是另一条路:生成代理子类。每个实体在运行时会得到一个 UserProxy 子类,重写所有 getter,在里面判断是否已初始化。这套方案有效,但需要写文件、需要缓存目录、类名变了之后老缓存会失效,部署时经常要清一遍。
PHP 8.4 的惰性对象把这件事下沉到了引擎层。不需要魔术方法,不需要代码生成,只要几行反射调用。
二、newLazyGhost:对象还在,只是还没填
先看最基础的用法。
class RemoteConfig
{
private array $data = [];
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
public function loadFromApi(): void
{
$this->data = json_decode(
file_get_contents('https://config.internal/api/settings'),
true
) ?: [];
}
}
这是一个从内网配置中心拉数据的类。构造它本身很便宜,但调用 loadFromApi() 要发一次 HTTP 请求,大概 80 毫秒。
用惰性对象的写法是这样的:
$reflector = new ReflectionClass(RemoteConfig::class);
$config = $reflector->newLazyGhost(function (RemoteConfig $self) {
$self->loadFromApi();
});
// 到这为止,一次 HTTP 请求都没有发生
echo "服务已启动n";
// 下面这行才会真的去拉数据
$name = $config->get('site_name', '未配置');
这段代码里 $config 是一个货真价实的 RemoteConfig 实例,instanceof、get_class() 都正常。区别在于它内部的属性槽位处于”未初始化”状态,第一次有人读或者写属性的时候,引擎才回头调用你在 newLazyGhost() 里传进去的那个闭包。
读和写都会触发初始化
这一点很重要,也很容易搞错。
$config = $reflector->newLazyGhost(fn(RemoteConfig $c) => $c->loadFromApi());
$config->get('key'); // 第一次读 → 触发初始化
$config->get('key2'); // 已经初始化了,直接走内存
但如果你这样写:
$config = $reflector->newLazyGhost(fn(RemoteConfig $c) => $c->loadFromApi());
// 想先手动设置一个字段?不行
$config->timeout = 5; // 这行就会触发初始化
写入也会初始化。原因不难理解——如果不这样的话,就会出现”对象一半是新的、一半是旧的”这种状态,谁也说不清读到的应该是哪一份。引擎选择了一个保守但清晰的规则:只要有人碰属性,就先老实把对象填好。
强制标记为已初始化
有些场景下你不需要跑初始化逻辑,但需要让对象进入”已初始化”状态。比如你已经从别的地方拿到了完整数据:
$reflector->markLazyObjectAsInitialized($config);
$reflector->isUninitializedLazyObject($config); // false
反过来,想看一个对象当前是不是还处于惰性状态:
if ($reflector->isUninitializedLazyObject($user)) {
// 还没触发加载
}
这两个方法在写调试工具或者测试的时候会用到。
三、案例一:ORM 的关联对象
把惰性对象用在数据层,收益最直接。
假设有订单和用户两张表:
class User
{
public int $id;
public string $nickname;
public string $email;
public ?string $avatar = null;
public string $bio = '';
public string $createdAt;
}
class Order
{
public int $id;
public string $orderNo;
public int $userId;
public string $amount;
private ?User $user = null;
public function setUser(User $user): void
{
$this->user = $user;
}
public function user(): User
{
if ($this->user === null) {
throw new RuntimeException('用户未加载');
}
return $this->user;
}
}
Repository 里加一个专门返回”惰性用户”的方法:
class UserRepository
{
public function __construct(private PDO $pdo) {}
public function findLazy(int $id): User
{
$reflector = new ReflectionClass(User::class);
return $reflector->newLazyGhost(function (User $user) use ($id) {
$row = $this->fetchFull($id);
if ($row === null) {
throw new RuntimeException("用户 {$id} 不存在");
}
$user->id = (int) $row['id'];
$user->nickname = $row['nickname'];
$user->email = $row['email'];
$user->avatar = $row['avatar'];
$user->bio = $row['bio'];
$user->createdAt = $row['created_at'];
});
}
private function fetchFull(int $id): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, nickname, email, avatar, bio, created_at
FROM users WHERE id = ?'
);
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
}
然后订单查询里,把用户对象挂上去的时候用 findLazy:
$orders = $pdo->query(
'SELECT id, order_no, user_id, amount FROM orders ORDER BY id DESC LIMIT 50'
)->fetchAll(PDO::FETCH_ASSOC);
$userRepo = new UserRepository($pdo);
$list = [];
foreach ($orders as $row) {
$order = new Order();
$order->id = (int) $row['id'];
$order->orderNo = $row['order_no'];
$order->userId = (int) $row['user_id'];
$order->amount = $row['amount'];
$order->setUser($userRepo->findLazy($order->userId));
$list[] = $order;
}
现在看效果:
// 只打印订单号,50 条记录,0 次用户表查询
foreach ($list as $order) {
echo $order->orderNo . PHP_EOL;
}
// 访问某一条的用户昵称,才会为那一条发一次查询
echo $list[0]->user()->nickname . PHP_EOL;
如果这个列表页只展示订单号,那用户表就一次都不会被查。如果只展示前三条订单的用户信息,那就只有三次查询。
这一点比”一次性全部加载”要省得多,也比”在模板里判断要不要加载”要干净——业务代码里看不到任何 if 判断,调用方像访问普通属性一样访问 nickname,剩下的交给引擎。
初始化里要注意递归
上面那个 fetchFull() 方法里,如果因为某种原因又去构造了这个用户的惰性对象,就会无限递归。写的时候心里要有数:初始化闭包里只做最底层的数据获取,不要再走一遍 Repository 的惰性路径。
一些 ORM 框架会在初始化时加一个递归深度计数,超了就抛异常。自己在项目里实现的话,可以简单用一个静态标记配合 try-finally。
四、newLazyProxy:对象本身也是延迟的
Ghost 的思路是”对象已经存在,只是属性还没填”。Proxy 是另一种思路:先给你一个空壳,真正干活的对象在你第一次用的时候才创建。
class ReportService
{
public function __construct(
private Database $db,
private Cache $cache,
private LoggerInterface $logger,
) {
// 假设构造函数里有一堆重活
$this->db->warmUp();
$this->cache->preloadTemplates();
}
public function generate(int $month): array { /* ... */ }
}
这个类的构造函数会做预热,代价不低。如果它在容器里被注册了,但实际上这次请求根本用不到它,就白干了。
$reflector = new ReflectionClass(ReportService::class);
$service = $reflector->newLazyProxy(function () {
return new ReportService(
Container::get(Database::class),
Container::get(Cache::class),
Container::get(LoggerInterface::class),
);
});
// 到这里为止,ReportService 的构造函数还没跑
// 调用方法时才会创建真实对象
$data = $service->generate(3);
注意两个细节。
第一,newLazyProxy 的回调必须返回一个对象,而且类型要和代理的类兼容。它不像 ghost 那样接收一个现成对象去填属性。
第二,代理对象和真实对象是两个不同的实例。$service !== $realService,spl_object_id() 也不一样。这一点在做对象标识比较的时候要注意。
怎么选 ghost 还是 proxy
给一个简单的判断方式:如果你能拿到需要的数据,只是暂时不想花那个代价去填属性——用 ghost。如果你连怎么构造这个对象都还没决定,或者构造过程本身很贵——用 proxy。
还有一条硬性条件:类里如果有只读属性,ghost 基本用不了。因为初始化闭包定义在类外面,没有权限写 readonly。这种情况下 proxy 是唯一的选项,因为它走的是”调用构造函数创建一个新对象”这条路,只读属性在构造函数里正常赋值就行。
给 Service 容器加一层包装
class Container
{
private array $definitions = [];
private array $instances = [];
public function set(string $id, callable $factory): void
{
$this->definitions[$id] = $factory;
}
public function get(string $id): object
{
if (isset($this->instances[$id])) {
return $this->instances[$id];
}
$factory = $this->definitions[$id]
?? throw new RuntimeException("未注册的服务:{$id}");
$reflector = new ReflectionClass($id);
$this->instances[$id] = $reflector->newLazyProxy(
fn() => $factory($this)
);
return $this->instances[$id];
}
}
注册的时候只是把一个工厂函数存起来,真正的实例化被推迟到了第一次实际使用。整个应用的启动开销可以下降不少——特别是那些只在个别路由里用到的重型服务。
这段代码当然省略了很多东西(循环依赖检测、单例/多例区分、参数注入、生命周期回调),但它说明了一件事:惰性对象让”延迟实例化”从框架黑魔法变成了一个可以自己写的十几行工具。
五、七个实际踩过的坑
1. var_dump 会意外触发加载
调试的时候顺手 var_dump($user),然后就发现本来应该懒加载的查询全部跑了一遍,性能分析图彻底乱掉。原因是 var_dump 要读属性值才能打印,读属性就触发初始化。
想避开这个问题,用 isUninitializedLazyObject() 先判断一下,或者用 markLazyObjectAsInitialized() 在看数据之前先标记掉。
同样的道理,json_encode()、get_object_vars()、以及大部分日志库的属性序列化都会触发。
2. instanceof 和 get_class 不会触发
这两件事不会触发初始化,是好事也是坑。
好处是 if ($user instanceof User) 这种判断很便宜,可以在类型检查之后再决定要不要真的去读字段。
坑在于,如果某个地方依赖 get_class() 来做分支判断,那么不管对象初始化没初始化,返回的都是同一个类名。想区分的话需要显式调用 isUninitializedLazyObject()。
3. 初始化抛异常不会锁定对象
如果你的初始化闭包里发了请求,而这次请求失败了:
$user = $repo->findLazy(42);
try {
echo $user->nickname;
} catch (RuntimeException $e) {
// 加载失败
}
// 稍后再试一次
echo $user->nickname; // 会重新执行初始化闭包
对象会保持在未初始化状态,下一次访问会再跑一次。这个行为有时候正合心意(比如网络抖动之后自动重试),有时候又会造成麻烦(比如初始化闭包里有副作用,被跑了两次)。
需要”失败一次就永久失败”的语义时,在初始化闭包外面加一层状态标记就好。
4. 序列化默认会触发初始化
把一个惰性对象丢进缓存或者队列之前,如果不做处理,序列化过程会把整个对象加载出来。
// 这样写会触发初始化
$payload = serialize($lazyUser);
// 想保留未初始化状态,创建时加这个选项
$user = $reflector->newLazyGhost(
$initializer,
ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE
);
反过来,从缓存里 unserialize() 出来的对象也是惰性的,它的初始化闭包是原来那个——如果闭包捕获了 PDO 连接这类不可序列化的东西,那个闭包就没法正确还原,访问时会出错。
跨进程传递惰性对象这件事,很难做对,不如干脆放弃。传 id,接收方重新构造。
5. 克隆行为需要想清楚
对一个还没初始化的 ghost 调用 clone,得到的新对象是”未初始化的副本”。两个对象共享同一个初始化闭包,但各自有独立的属性槽。
这意味着如果你克隆了一个已经初始化的惰性对象,副本是初始化的;克隆了未初始化的,副本也是未初始化的。看起来合理,但实际用的时候容易忘记这一点——特别是那种”先克隆一份,改改再用”的写法。
6. 惰性不等于批量
这一点最容易被误解。惰性对象让”用不到就不查”变成默认行为,但它没有解决 N+1。
如果 50 条订单你都要展示用户昵称,那就是 50 次查询,一次不少。
批量加载需要的是另一套机制:先把所有 userId 收起来,一次查询拉回来,然后分发。(Doctrine 的做法是用一个 DataLoader 在事务提交前统一处理。)
惰性对象的定位是”减少不必要的加载”,不是”合并加载”。搞清楚这一点,才不会对它有不切实际的期待。
7. 类的限制
不是所有类都能做惰性对象。interface、trait、enum、abstract 类自然不行。部分 internal 类也不行,因为引擎需要能控制它们的属性布局。
只读属性的问题前面提过了,ghost 基本用不了。至于 final 类,行为在不同版本上可能有一些变化,上线前在自己项目的 PHP 版本上实测一遍最稳。
六、该不该用
惰性对象解决的是一个具体的性能问题:对象的构造代价(或者它的数据加载代价)在某些使用路径上是浪费的。如果你的项目里符合下面任何一条,值得试一试。
一是列表页和详情页共用同一套实体类,但列表页只用到很少的字段。二是应用里有几个重型服务在很多请求里根本用不到。三是数据加载涉及网络请求或者复杂计算,而你希望”不用就不算”。四是自己写 ORM 或者 DI 容器,不想再维护代码生成的代理类文件。
不适合的场景也很清楚。
如果你的数据本来就来自一次查询,加载代价为零,那惰性对象只是增加了复杂度。如果团队里对反射还不熟悉,引入之后调试成本可能比收益大。如果是老项目,大量代码依赖 get_object_vars() 或者 json_encode 来序列化对象,惰性对象的引入会带来一堆难查的副作用。
另外有一点值得留意:惰性对象是运行时优化,它不改变任何对外行为——至少设计上如此。所以它可以在项目已经稳定之后,从一两个热点开始慢慢引入,不需要一次性铺开。
七、写在最后
回头看开头那个用户列表页。
用惰性对象改完之后,SQL 从 101 条降到了 1 条——只有列表本身那次查询,用户表一次都没碰。因为那个页面只展示头像,而头像是在订单表里冗余存储的。
更重要的变化其实不在这里。原来的代码需要小心翼翼地维护”这个页面用哪些字段”,加了新字段就可能出问题,改了布局就可能让缓存失效。现在这套判断完全交给引擎和运行时,业务代码里没有一处 if 判断要为了性能而写。这一点带来的长期收益,比那 1.8 秒要值钱得多。
PHP 这些年的更新里,很多特性都是这个路数:把过去只能靠框架黑魔法或者代码生成来做的事情,变成语言内置的能力。属性钩子是这样,惰性对象也是这样。每次都要重新学一遍新的 API 有点累,但那些因为方案不统一而写出来的绕过代码,确实少了很多。

