Sti 单表继承在 Laravel 中的实现方案

解读

“单表继承”(Single Table Inheritance,简称 STI)是 ORM 里一种“多子类共用一张表”的映射策略:一张表通过 type 字段区分不同子类,子类只重写行为而不额外建表。
国内 Laravel 项目里,STI 常被用来解决“业务实体高度相似、字段几乎一致,但逻辑差异大”的场景,例如:

  • 订单类型(普通订单、拼团订单、秒杀订单)
  • 付款流水(微信、支付宝、银行卡)
  • 消息通知(站内信、短信、邮件)

面试官问“实现方案”,核心想验证四点:

  1. 知不知道 Laravel 默认就支持 STI,只需 protected $tableprotected $fillable 配合 type 字段即可;
  2. 会不会规避 STI 的副作用:查询作用域污染、字段膨胀、索引失效;
  3. 能否在迁移、模型、服务层给出可落地的代码骨架;
  4. 是否清楚 STI 与 CTI(Class Table Inheritance)、多表继承、多态关联的区别,避免滥用。

知识点

  1. Eloquent 约定:父类模型中 protected $table = 'xxx';子类不再声明 $table,但必须声明 protected $fillableprotected $guarded;表内必须存在 type 字段(可自定义名称)。
  2. 父类作用域:使用 newQuery() 时,Eloquent 会自动追加 where type = xxx,因此父类查询默认不包含子类;若想查全部,需手动 ->withoutGlobalScopes() 或定义全局作用域。
  3. 迁移设计:所有子类可能用到的字段都打在一张表,易出现“宽表”;需给 type 加普通索引,给差异化查询字段加联合索引,防止全表扫描。
  4. 字段冗余与校验:子类用不到的字段要在业务层做校验,可借助 FormRequestDTO;否则会出现“支付宝流水保存了微信专属字段”的脏数据。
  5. 事件与观察者:子类可单独监听 creatingupdating,实现差异化业务,例如“秒杀订单下单时自动扣库存”。
  6. 反向生成:如果存量表已存在大量数据,需要写一次性脚本补全 type 字段,否则父类 ::all() 会漏数据。
  7. 测试策略:Factory 里使用 state 为不同子类造数据;Feature Test 要覆盖“父类查全部、子类查自己、更新子类字段”三条主线。
  8. 替代方案:
    • 多态关联(morphTo):每个子类独立表,结构可不同;
    • CTI:共用主表,差异字段拆子表,Laravel 无内建支持,需手动 join
    • 枚举策略:如果差异仅一两个字段,可用 enum+json 字段,不走 STI。

答案

下面给出一套可直接写进简历项目、经得起面试官逐行追问的“国内电商订单类型”示例。

  1. 迁移(省略时间戳)
Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->string('type')->index()->comment('子类类型');
    $table->string('order_no')->unique();
    $table->unsignedBigInteger('user_id')->index();
    $table->decimal('amount', 10, 2);
    // 普通订单字段
    $table->string('delivery_address')->nullable();
    // 拼团订单字段
    $table->unsignedBigInteger('group_id')->nullable()->index();
    $table->timestamp('joined_at')->nullable();
    // 秒杀订单字段
    $table->unsignedBigInteger('seckill_id')->nullable()->index();
    $table->timestamp('paid_at')->nullable();
    $table->timestamps();
});
  1. 父类模型 app/Models/Order.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;

abstract class Order extends Model
{
    protected $table = 'orders';

    // 关键:开启 STI
    public $timestamps = true;

    // 统一暴露给外部的字段,子类按需追加
    protected $fillable = [
        'order_no', 'user_id', 'amount', 'delivery_address',
        'group_id', 'joined_at', 'seckill_id', 'paid_at'
    ];

    // 全局作用域:默认只查当前子类
    protected static function boot()
    {
        parent::boot();
        static::addGlobalScope('type', function (Builder $builder) {
            $builder->where('type', static::class);
        });
    }

    // 反向生成脚本里用到:查全部类型
    public function newQueryWithoutTypeScope()
    {
        return parent::newQuery()->withoutGlobalScopes();
    }
}
  1. 子类模型
// 普通订单
namespace App\Models\Orders;

use App\Models\Order;

class NormalOrder extends Order
{
    // 不需要再写 $table
    protected $fillable = ['delivery_address'];

    public static function createFor(User $user, array $address)
    {
        return static::create([
            'order_no' => generate_order_no(),
            'user_id'  => $user->id,
            'amount'   => 0,
            'delivery_address' => $address,
        ]);
    }
}

// 拼团订单
namespace App\Models\Orders;

class GroupOrder extends Order
{
    protected $fillable = ['group_id'];

    protected static function boot()
    {
        parent::boot();
        // 子类专属事件
        static::creating(function ($order) {
            $order->joined_at = now();
        });
    }

    public function group()
    {
        return $this->belongsTo(Group::class);
    }
}

// 秒杀订单
namespace App\Models\Orders;

class SeckillOrder extends Order
{
    protected $fillable = ['seckill_id'];

    public function seckill()
    {
        return $this->belongsTo(Seckill::class);
    }

    // 差异化业务:下单即锁定库存
    public function confirm()
    {
        \DB::transaction(function () {
            $this->seckill->decrement('stock');
            $this->update(['paid_at' => now()]);
        });
    }
}
  1. 仓库层(可选,面试可讲)
namespace App\Repositories;

use App\Models\Order;
use Illuminate\Support\Collection;

class OrderRepo
{
    // 后台运营视角:查全部类型
    public function allOrders(): Collection
    {
        return Order::withoutGlobalScopes()->with('user')->get();
    }

    // 用户视角:只查自己的子类订单
    public function userOrders($userId, string $type = null)
    {
        $query = Order::withoutGlobalScopes()
                      ->where('user_id', $userId);
        if ($type) {
            $query->where('type', $type);
        }
        return $query->get()->map->toArray(); // 可统一DTO
    }
}
  1. 使用示例
$order = GroupOrder::create([
    'order_no' => generate_order_no(),
    'user_id'  => 1,
    'amount'   => 99,
    'group_id' => 123,
]);

// 查询
$normalOrders = NormalOrder::where('user_id', 1)->get(); // 自动带 type = NormalOrder
$allOrders    = (new OrderRepo)->allOrders();            // 全部类型
  1. 踩坑与优化(面试主动说)
  • 宽表字段 > 30 个时,建议垂直拆“扩展表”,用 order_extend 存 json,减少单行字节;
  • type 字段长度用 varchar(64) 足够,但禁止用 text
  • 索引顺序:(user_id, type)(type, user_id) 更能覆盖用户视角查询;
  • 禁止在子类里重写 getTable(),会导致 update 时表名不一致;
  • 事务内批量插入时,记得手动写入 type 字段,否则 type 为 null 会被全局作用域过滤掉。

拓展思考

  1. 如果未来拼团订单需要 20 个独有字段,而秒杀订单需要 30 个,STI 宽表已超 100 列,如何平滑迁移?
    答:

    • 第一步:新建 group_order_extendsseckill_order_extends 子表,与原表 1:1;
    • 第二步:模型里用 hasOne 关联,访问器统一封装,外部调用无感;
    • 第三步:灰度双写,存量数据脚本迁移,观察两周后下掉原表字段。
  2. 面试官追问“STI 与多态关联性能差异”?
    答:

    • STI 查询只需一次主表 IO,适合读多写少、子类差异小;
    • 多态关联需要 union 或多次 join,子表可独立加索引,适合差异大、并发写高;
    • 国内 MySQL 5.7 实例,1000 万行 STI 表,type 过滤走索引,QPS 压测 4000 仍保持 10 ms 内;多态关联 union 后 QPS 掉 30%,但字段隔离更好。
  3. 如何防止前端传错 type 导致数据污染?
    答:

    • 路由层用不同 controller 隔离,不走统一 OrderController@store
    • 表单请求使用独立 FormRequest,各自校验字段;
    • 服务层使用“策略工厂”根据业务场景创建对应子类,禁止前端传 type 参数。
  4. 面试加分:用 Laravel 9 的 Enum 改写 type 字段,让 IDE 可自动跳转。

enum OrderType: string
{
    case Normal  = 'App\Models\Orders\NormalOrder';
    case Group   = 'App\Models\Orders\GroupOrder';
    case Seckill = 'App\Models\Orders\SeckillOrder';
}

迁移里 typestring->default(OrderType::Normal->value),模型里 resolveChildModelOrderType::tryFrom(),保证重构后类型安全。