模型持久化与加载

解读

在国内 PHP 面试中,“模型持久化与加载”并不是单纯问“怎么把对象存进数据库”,而是考察候选人能否把“业务模型”与“存储细节”解耦,并在高并发、低延迟、可扩展的真实业务场景里落地。面试官想听到的是:

  1. 你能否用面向对象的方式描述业务(实体、值对象、聚合根、仓储)。
  2. 你能否在不牺牲性能的前提下,把对象状态安全、一致地落库,并在需要时完整重建。
  3. 你能否说清楚 ORM 底层机制、延迟加载、N+1、事务边界、缓存一致性、字段变更追踪、分库分表后的 ID 映射等“坑”与“解”。
  4. 你能否在 Composer 生态里给出可落地的代码骨架,而不是背八股文。

一句话:让“模型”脱离“数据库”,又让“数据库”为“模型”服务。

知识点

  1. 领域模型 vs 数据模型:贫血模型、充血模型、DDD 聚合根、仓储模式(Repository)。
  2. ORM 双轨:ActiveRecord(Yii2、ThinkPHP 模型基类)与 DataMapper(Doctrine、Eloquent 的底层实现)。
  3. 持久化生命周期:new → persist → flush → commit → 事务提交 → 返回写入后 ID。
  4. 对象状态追踪:UnitOfWork(Doctrine)、Model::isDirty()(Laravel)、字段级变更集。
  5. 加载策略:即时加载、延迟加载(Proxy、Ghost)、贪婪加载(with/join)、子查询加载、游标批量加载。
  6. 身份映射(IdentityMap)与一级缓存:同一请求内同一主键只查询一次,防止重复对象。
  7. 二级缓存 / 应用缓存:Redis、Memcached、ORM 查询结果缓存、实体缓存、版本戳(etag)与缓存雪崩、穿透、并发写一致。
  8. 事务与并发:悲观锁(SELECT … FOR UPDATE)、乐观锁(version 字段 / CAS)、Saga / TCC 补偿。
  9. 分库分表后的主键生成:雪花算法、Leaf-segment、UUID、MySQL 8.0 有序 UUID。
  10. 性能陷阱:N+1、深分页(OFFSET 爆炸)、大字段(text/json)冗余、JSON 列索引、延迟加载导致的连接池耗尽。
  11. 序列化格式:PHP serialize、JSON、MessagePack、Protobuf,以及 __sleep / __wakeup、Serializable 接口。
  12. 安全:SQL 注入(已杜绝)、反序列化 gadget、字段脱敏、加密列(MySQL AES、字段级透明加密)。

答案

以下示例基于 Laravel 8+(国内占有率最高),但刻意用 Repository 接口隔离 Eloquent,方便随时换成 Doctrine 或原生 SQL,体现“持久化与加载”的抽象思想。

  1. 领域模型(充血)
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…
}
  1. 仓储接口(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;
}
  1. 仓储实现(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;
    }
}
  1. 应用服务(用例层)
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;
    }
}
  1. 性能优化要点(面试时必须主动说)
  • 使用 with('items') 贪婪加载,避免 N+1。
  • 订单表按 user_id 分表,用雪花 ID 保证全局有序,查询时带分片键。
  • 二级缓存:订单确认后写入 Redis,Key 为 order:{id},TTL 600s,更新时先删缓存再写库(Cache-Aside)。
  • 乐观锁:表加 version 字段,store() 时 WHERE 条件带版本号,防止并发写覆盖。
  • 大字段(如发票 JSON)单独拆表或放对象存储,避免拖慢主表缓存页。
  1. 加载策略代码示例
// 延迟加载:只在需要时查 items
$order = $repo->ofId($id);        // 此时 items 未加载
$items = $order->items();         // 访问时才触发 SQL(Ghost 代理)

// 贪婪加载:一次 JOIN 拿回所有行
$order = OrderModel::with('items')->find($id);

拓展思考

  1. 如果公司用 Doctrine,如何启用二级缓存(Redis)并配置查询缓存、实体缓存、结果缓存三级策略?
  2. 在秒杀场景,订单聚合根持久化需要扣减库存,如何保证“订单落库”与“库存扣减”分布式事务一致?(答案:Saga + 本地消息表 / RocketMQ 事务消息)
  3. 当订单表膨胀到 10 亿行,MySQL 分 1024 表后,Eloquent 的 with() 无法跨分片 join,如何改造仓储实现?(答案:内存聚合,先查订单表拿到 order_id 列表,再用 whereIn(order_id, ...) 批量查行表,最后在内存组装聚合根)
  4. PHP 8.3 的只读类、枚举、构造器属性提升,对“充血模型”代码风格带来哪些简化?
  5. 如果要把订单快照持久化到对象存储(OSS/S3),用预签名 URL 返回给前端,如何设计序列化格式与版本兼容策略?