模型持久化与加载
解读
在国内 PHP 面试中,“模型持久化与加载”并不是单纯问“怎么把对象存进数据库”,而是考察候选人能否把“业务模型”与“存储细节”解耦,并在高并发、低延迟、可扩展的真实业务场景里落地。面试官想听到的是:
- 你能否用面向对象的方式描述业务(实体、值对象、聚合根、仓储)。
- 你能否在不牺牲性能的前提下,把对象状态安全、一致地落库,并在需要时完整重建。
- 你能否说清楚 ORM 底层机制、延迟加载、N+1、事务边界、缓存一致性、字段变更追踪、分库分表后的 ID 映射等“坑”与“解”。
- 你能否在 Composer 生态里给出可落地的代码骨架,而不是背八股文。
一句话:让“模型”脱离“数据库”,又让“数据库”为“模型”服务。
知识点
- 领域模型 vs 数据模型:贫血模型、充血模型、DDD 聚合根、仓储模式(Repository)。
- ORM 双轨:ActiveRecord(Yii2、ThinkPHP 模型基类)与 DataMapper(Doctrine、Eloquent 的底层实现)。
- 持久化生命周期:new → persist → flush → commit → 事务提交 → 返回写入后 ID。
- 对象状态追踪:UnitOfWork(Doctrine)、Model::isDirty()(Laravel)、字段级变更集。
- 加载策略:即时加载、延迟加载(Proxy、Ghost)、贪婪加载(with/join)、子查询加载、游标批量加载。
- 身份映射(IdentityMap)与一级缓存:同一请求内同一主键只查询一次,防止重复对象。
- 二级缓存 / 应用缓存:Redis、Memcached、ORM 查询结果缓存、实体缓存、版本戳(etag)与缓存雪崩、穿透、并发写一致。
- 事务与并发:悲观锁(SELECT … FOR UPDATE)、乐观锁(version 字段 / CAS)、Saga / TCC 补偿。
- 分库分表后的主键生成:雪花算法、Leaf-segment、UUID、MySQL 8.0 有序 UUID。
- 性能陷阱:N+1、深分页(OFFSET 爆炸)、大字段(text/json)冗余、JSON 列索引、延迟加载导致的连接池耗尽。
- 序列化格式:PHP serialize、JSON、MessagePack、Protobuf,以及 __sleep / __wakeup、Serializable 接口。
- 安全:SQL 注入(已杜绝)、反序列化 gadget、字段脱敏、加密列(MySQL AES、字段级透明加密)。
答案
以下示例基于 Laravel 8+(国内占有率最高),但刻意用 Repository 接口隔离 Eloquent,方便随时换成 Doctrine 或原生 SQL,体现“持久化与加载”的抽象思想。
- 领域模型(充血)
namespace Domain\Sales;
final class Order
{
private OrderId $id;
private int $userId;
private Money $totalAmount;
private OrderStatus $status;
private Collection $items; // ArrayCollection<OrderItem>
public function __construct(OrderId $id, int $userId, Money $totalAmount)
{
$this->id = $id;
$this->userId = $userId;
$this->totalAmount = $totalAmount;
$this->status = OrderStatus::PENDING();
$this->items = new ArrayCollection();
}
public function addItem(Product $product, int $quantity): void
{
$this->items->add(new OrderItem($this->id, $product->id(), $quantity, $product->price()));
$this->totalAmount = $this->totalAmount->add($product->price()->multiply($quantity));
}
public function confirm(): void
{
if (!$this->status->equals(OrderStatus::PENDING())) {
throw new DomainException('Only pending order can be confirmed');
}
$this->status = OrderStatus::CONFIRMED();
}
// 省略 getter…
}
- 仓储接口(Domain 层,不依赖任何 ORM)
namespace Domain\Sales;
interface OrderRepository
{
public function nextIdentity(): OrderId;
public function store(Order $order): void;
public function ofId(OrderId $id): ?Order;
public function ofUser(int $userId, int $page, int $size): Paginator;
}
- 仓储实现(Infrastructure 层,用 Eloquent 当 DAO)
namespace Infrastructure\Persistence;
use Domain\Sales\{Order, OrderId, OrderRepository};
use Illuminate\Database\Eloquent\{Builder, Model};
class EloquentOrderRepository implements OrderRepository
{
public function nextIdentity(): OrderId
{
return OrderId::of((new Snowflake)->id());
}
public function store(Order $order): void
{
// 事务内完成头表 + 行表写入,保证聚合一致性
\DB::transaction(function () use ($order) {
OrderModel::updateOrCreate(
['id' => $order->id()->value()],
[
'user_id' => $order->userId(),
'total_amount' => $order->totalAmount()->getAmount(),
'currency' => $order->totalAmount()->getCurrency(),
'status' => $order->status()->getValue(),
]
);
// 先删后插,简化行表同步
OrderItemModel::where('order_id', $order->id()->value())->delete();
foreach ($order->items() as $item) {
OrderItemModel::create([
'order_id' => $item->orderId()->value(),
'product_id' => $item->productId(),
'quantity' => $item->quantity(),
'price' => $item->price()->getAmount(),
]);
}
});
}
public function ofId(OrderId $id): ?Order
{
$po = OrderModel::with('items')->find($id->value());
return $po ? $this->toEntity($po) : null;
}
private function toEntity(OrderModel $po): Order
{
$order = new Order(
OrderId::of($po->id),
$po->user_id,
new Money($po->total_amount, $po->currency)
);
// 利用反射或私有构造快速恢复状态,此处简化
foreach ($po->items as $itemPo) {
$item = new OrderItem(
OrderId::of($itemPo->order_id),
$itemPo->product_id,
$itemPo->quantity,
new Money($itemPo->price, $po->currency)
);
$order->getItems()->add($item);
}
return $order;
}
}
- 应用服务(用例层)
class PlaceOrderService
{
public function __construct(
private OrderRepository $orders,
private ProductRepository $products
) {}
public function execute(int $userId, array $skus): OrderId
{
$orderId = $this->orders->nextIdentity();
$order = new Order($orderId, $userId, Money::zero('CNY'));
foreach ($skus as $sku => $qty) {
$product = $this->products->ofSku($sku);
$order->addItem($product, $qty);
}
$order->confirm();
$this->orders->store($order); // 持久化
return $orderId;
}
}
- 性能优化要点(面试时必须主动说)
- 使用
with('items')贪婪加载,避免 N+1。 - 订单表按
user_id分表,用雪花 ID 保证全局有序,查询时带分片键。 - 二级缓存:订单确认后写入 Redis,Key 为
order:{id},TTL 600s,更新时先删缓存再写库(Cache-Aside)。 - 乐观锁:表加
version字段,store()时 WHERE 条件带版本号,防止并发写覆盖。 - 大字段(如发票 JSON)单独拆表或放对象存储,避免拖慢主表缓存页。
- 加载策略代码示例
// 延迟加载:只在需要时查 items
$order = $repo->ofId($id); // 此时 items 未加载
$items = $order->items(); // 访问时才触发 SQL(Ghost 代理)
// 贪婪加载:一次 JOIN 拿回所有行
$order = OrderModel::with('items')->find($id);
拓展思考
- 如果公司用 Doctrine,如何启用二级缓存(Redis)并配置查询缓存、实体缓存、结果缓存三级策略?
- 在秒杀场景,订单聚合根持久化需要扣减库存,如何保证“订单落库”与“库存扣减”分布式事务一致?(答案:Saga + 本地消息表 / RocketMQ 事务消息)
- 当订单表膨胀到 10 亿行,MySQL 分 1024 表后,Eloquent 的
with()无法跨分片 join,如何改造仓储实现?(答案:内存聚合,先查订单表拿到 order_id 列表,再用whereIn(order_id, ...)批量查行表,最后在内存组装聚合根) - PHP 8.3 的只读类、枚举、构造器属性提升,对“充血模型”代码风格带来哪些简化?
- 如果要把订单快照持久化到对象存储(OSS/S3),用预签名 URL 返回给前端,如何设计序列化格式与版本兼容策略?