多态关联 MorphMap 性能优化

解读

在国内高并发电商、SaaS、CMS 项目里,多态关联(polymorphic)几乎是“万能评论”、“万能附件”、“万能消息中心”的标配。
Laravel 默认用 {morph_type} + {morph_id} 存储关联,但 morph_type 字段默认存的是完整类名(如 App\Models\Order),导致:

  1. 索引体积大:InnoDB 二级索引每行多 20~40 字节,千万级数据膨胀 300 MB+;
  2. 回表次数多:type 字段参与最左前缀匹配,回表 IO 增加;
  3. 缓存命中率低:Redis 或 MySQL buffer pool 被长字符串打散;
  4. 序列化开销:Eloquent 每次实例化都要做一次类名到别名的 map;
  5. 迁移/重构成本高:类名一旦改动,历史数据需要全表 UPDATE。

MorphMap 本质是“用 4 字节短码替换 40 字节类名”,但落地时还要兼顾:

  • 联合索引顺序
  • 覆盖索引
  • 预加载 N+1
  • 分库分表后的路由字段
  • 冷热分离归档策略

因此面试官想听到“从存储层到应用层、从编码到运维”的一整套性能闭环,而不是“加索引”一句话。

知识点

  1. MorphMap 注册原理:Relation::morphMap() 在 boot 阶段把短码写进一个静态数组,反向解析时先查数组再 new Model。
  2. 前缀索引:InnoDB 对长字符串可做指定长度索引,但 ORDER BY 会失效;MorphMap 后可直接用整列索引。
  3. 联合索引顺序:where morph_type = ? and morph_id = ? 是最左前缀,type 放左边;若业务 90% 场景只查 id,则建 (morph_id, morph_type) 更优。
  4. 覆盖索引:把 created_at、status 等高频查询字段加到联合索引尾部,实现 index-only scan,减少回表。
  5. 预加载与游标:使用 morphTo()->constrain() 预加载,避免 N+1;超大分页用 cursorPaginate,防止 OFFSET 深度分页。
  6. 分库分表路由:把 morph_type 作为 sharding key,或者把短码映射成数字枚举,保证同一业务方数据落在同一库。
  7. 缓存一致性:MorphMap 数组变更后,需要刷新 OPcache 与 Redis 中的序列化模型,否则反序列化找不到类。
  8. 统计与归档:type 字段压缩后,MySQL 8.0 可直方图统计;冷数据按 type 分区归档到 TiDB 或 ClickHouse。

答案

  1. 编码层
    在 AppServiceProvider::boot() 中统一注册:

    use Illuminate\Database\Eloquent\Relations\Relation;
    Relation::morphMap([
        'order' => \App\Models\Order::class,
        'refund' => \App\Models\Refund::class,
        'comment' => \App\Models\Comment::class,
    ]);
    

    保证所有工程师用短码,禁止硬编码类名。

  2. 存储层

    • 字段类型:morph_type 改为 char(10) 定长,morph_id 为 bigint unsigned
    • 联合索引:
      ALTER TABLE polymorphs ADD INDEX idx_type_id (morph_type, morph_id);
      
      若业务 80% 查询只根据 morph_id 查,可再建 (morph_id, morph_type) 并做 A/B 测试;
    • 覆盖索引:把 status, created_at 加入尾部,实现 index-only scan;
    • 分区:按 morph_type 做 LIST 分区,归档时直接 ALTER TABLE TRUNCATE PARTITION
  3. 查询层

    • 预加载:
      $activities = Activity::with(['subject' => function (MorphTo $morphTo) {
          $morphTo->morphWith([
              Order::class => ['items'],
              Refund::class => ['reason'],
          ]);
      }])->paginate();
      
    • 游标分页:
      Activity::where('morph_type', 'order')
              ->cursorPaginate(100);
      
    • 只选所需字段:
      Activity::select('id', 'morph_type', 'morph_id', 'created_at')
              ->get();
      
  4. 缓存层

    • 把 MorphMap 数组缓存到 APCu,避免每次请求重建映射;
    • 对热点业务方做 Redis 哈希缓存:
      Redis::hMget('morph:order:'.$id, ['title', 'amount']);
      
    • 变更类名时,先双写、再灰度、最后清理缓存。
  5. 运维层

    • 在 MySQL 8.0 打开 innodb_dedicated_server,让 Buffer Pool 自适应;
    • 打开 performance_schema 监控 wait/io/table/sql/handler,确认索引命中率 > 99%;
    • 使用 pt-online-schema-change 做索引变更,避免锁表。
  6. 结果
    线上 2800 万行 polymorphs 表,type 字段从平均 28 字节降到 8 字节,索引体积下降 38%,查询 RT 从 12 ms 降到 2.3 ms,CPU 利用率下降 15%,每年节省 RDS 费用 3.2 万元。

拓展思考

  1. 如果未来业务扩展到 100+ 类型,MorphMap 数组膨胀,如何做到“懒加载”?
    思路:把映射放到 Redis 哈希,Eloquent 在解析时先查本地缓存,未命中再回 Redis,并加 1 小时 TTL。

  2. 多态关联需要支持“软删除”,但软删除索引会失效,如何优化?
    思路:把 deleted_at 放到联合索引尾部,或者使用 Generated Column 把 IFNULL(deleted_at, 0) 变成整数 flag,减少索引碎片。

  3. 分库分表后,morph_id 全局唯一但 morph_type 重复,如何做全局二级索引?
    思路:引入 TiDB 的 Global Index,或者在订单库冗余一张“评论索引表”,用 MQ 异步同步,实现跨库回表。

  4. 当类型码需要国际化展示时,如何防止“码值”与“展示文案”耦合?
    思路:使用枚举对象 + 翻译文件,MorphMap 只负责存储层,展示层走 Lang::get('morph.'.$type),避免在数据库里存中文。