大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
数据库与 Eloquent ORM
这一课你将能做到
- 用迁移(migration)版本化地建表改表,用工厂与填充造出测试数据
- 定义一对一、一对多、多对多关系,并通过关系进行预加载与跨表查询
- 识别并治理 N+1 查询,用事务保证一致性,知道何时回落到查询构造器
为什么要学
数据层是后端的半壁江山。Spring 阵营有 JPA/Hibernate 与 MyBatis 两派:前者面向对象、自动映射,后者 SQL 可控。Eloquent 属于前者——Active Record 模式,一个模型类对应一张表,实例即一行记录,关系用方法表达。它的开发效率非常高,配合迁移可以让整个团队的表结构演进像代码一样可追踪、可回滚。
但任何 ORM 都有代价:隐藏 SQL 后容易写出低效查询。真实项目里最常见的性能事故就是 N+1 和隐式全表扫描。本课不仅讲「怎么写」,还讲「生成的 SQL 是什么」「怎么避免坑」,这是从会用框架到能扛项目的分界线。
先建立图像
一个简化电商数据模型:
users 1───N orders N───N products(通过 order_items / 或 product_order 透视表)
orders N───1 addresses
products N───1 categories
Eloquent 关系对照:
| 关系 | Laravel 方法 | JPA 等价 |
|---|---|---|
| 一对一 | hasOne / belongsTo | @OneToOne |
| 一对多 | hasMany / belongsTo | @OneToMany / @ManyToOne |
| 多对多 | belongsToMany(透视表) | @ManyToMany |
| 远层一对多 | hasManyThrough | 两次 join 手写 |
| 多态 | morphMany / morphTo | @Any…(较绕) |
跟着做
步骤 1:写迁移建表
php artisan make:migration create_orders_table
php artisan make:migration create_order_items_table
php artisan make:model Order -m # 一步生成模型 + 迁移
php artisan make:model OrderItem -m
database/migrations/xxxx_create_orders_table.php:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('order_no', 32)->unique();
$table->unsignedBigInteger('amount')->comment('订单总额,单位:分');
$table->string('status', 16)->default('pending')->index();
$table->string('channel', 16)->default('mock');
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
});
order_items 表:
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->string('sku', 32);
$table->unsignedInteger('price'); // 单价(分)
$table->unsignedInteger('quantity');
$table->timestamps();
});
执行与回滚:
php artisan migrate # 执行未运行的迁移
php artisan migrate:rollback --step=1
php artisan migrate:fresh # 删全部表重跑(开发用,生产严禁)
修改已有表用新迁移而不是改旧文件(旧文件可能已在别人的环境执行过):
php artisan make:migration add_remark_to_orders_table --table=orders
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->string('remark', 200)->nullable()->after('channel');
});
}
步骤 2:定义模型与关系
app/Models/Order.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
// 允许批量赋值的字段($request->validated() 写入时需要)
protected $fillable = ['order_no', 'amount', 'status', 'channel', 'remark', 'paid_at'];
// 类型转换:从库中取出时自动转为 Carbon / bool 等
protected $casts = [
'paid_at' => 'datetime',
'amount' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
// 本地作用域:复用查询条件
public function scopePaid($query)
{
return $query->where('status', 'paid');
}
}
app/Models/OrderItem.php:
class OrderItem extends Model
{
protected $fillable = ['product_id', 'sku', 'price', 'quantity'];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}
User 模型里补上:
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
步骤 3:基本 CRUD
use App\Models\Order;
// 新增
$order = Order::create([
'order_no' => 'NO'.now()->format('YmdHis').str_pad(random_int(0, 9999), 4, '0', STR_PAD_LEFT),
'amount' => 19900,
'status' => 'pending',
'channel' => 'alipay',
]);
// 查询
$order = Order::findOrFail(1);
$paid = Order::query()->paid()->where('user_id', $id)->orderByDesc('id')->get();
$one = Order::where('order_no', $no)->firstOrFail();
// 更新
$order->update(['status' => 'paid', 'paid_at' => now()]);
// 删除
$order->delete();
create() 依赖 $fillable 白名单(防批量赋值攻击);不想逐个列白名单可用 $guarded = [](等于关闭防护,仅内部系统谨慎使用)。
步骤 4:通过关系读写
// 创建订单时一并写明细(关系的 save / createMany)
$order = User::findOrFail($userId)->orders()->create([
'order_no' => $orderNo,
'amount' => $total,
'status' => 'pending',
'channel' => $channel,
]);
$order->items()->createMany([
['product_id' => 1, 'sku' => 'SKU-1', 'price' => 9900, 'quantity' => 2],
['product_id' => 2, 'sku' => 'SKU-2', 'price' => 100, 'quantity' => 1],
]);
// 动态属性(触发一次查询)
foreach ($order->items as $item) {
echo $item->sku;
}
// 跨关系查询:找出买过某 SKU 的用户
$users = User::whereHas('orders.items', function ($q) {
$q->where('sku', 'SKU-1');
})->get();
步骤 5:预加载,消灭 N+1
错误写法(1 次查订单 + N 次查每个订单的用户):
$orders = Order::latest()->take(50)->get();
foreach ($orders as $order) {
echo $order->user->name; // 每次循环都查库
}
正解:
$orders = Order::with(['user', 'items.product'])->latest()->paginate(20);
foreach ($orders as $order) {
echo $order->user->name; // 不额外查库
echo $order->items->count();
}
// 只在某些条件下加载
$orders = Order::with(['items' => fn ($q) => $q->where('quantity', '>', 1)])->get();
// 统计关联数量而不加载集合
$users = User::withCount('orders')->having('orders_count', '>', 0)->get();
开发期可以开启严格的 N+1 监控(AppServiceProvider::boot()):
Model::preventLazyLoading(! app()->isProduction());
步骤 6:事务保证一致性
下单要同时写 orders、order_items、扣库存,必须同生共死:
use Illuminate\Support\Facades\DB;
$order = DB::transaction(function () use ($payload, $items) {
$order = Order::create($payload);
$order->items()->createMany($items);
foreach ($items as $item) {
$affected = DB::table('products')
->where('id', $item['product_id'])
->where('stock', '>=', $item['quantity']) // 乐观条件,防超卖
->decrement('stock', $item['quantity']);
if ($affected === 0) {
throw new \RuntimeException('库存不足');
}
}
return $order;
}, attempts: 3); // 死锁时自动重试 3 次
注意:抛出异常时事务自动回滚;事务内调用外部 HTTP(支付接口)要谨慎——外部调用不会随数据库回滚,应在事务提交后再发起。
步骤 7:查询构造器与分页
Eloquent 表达不了的复杂报表可以直接用查询构造器(仍然自动绑定参数,防注入):
$stats = DB::table('orders')
->selectRaw('DATE(created_at) AS day, COUNT(*) AS cnt, SUM(amount) AS gmv')
->where('status', 'paid')
->groupByRaw('DATE(created_at)')
->orderByDesc('day')
->limit(30)
->get();
// 分页
$orders = Order::with('items')->where('user_id', $uid)->paginate(20);
// 游标分页(大表深翻页性能更好,不产生 OFFSET)
$orders = Order::where('id', $cursor)->cursorPaginate(20);
讲清楚要点
- 迁移是团队契约:只增不改旧文件;迁移要能 up 也能 down;外键用
foreignId()->constrained(),删除策略(cascade/restrict)显式声明。 - Active Record 的双刃:写业务很爽(
$order->items()->create()),但模型容易承载过多职责。复杂查询用 scope 或专门的查询类隔离,别让模型变成几千行的上帝类。 - N+1 是默认行为:访问关系动态属性就会即时查询;列表场景一律
with()预加载。可用preventLazyLoading与调试栏(Laravel Debugbar)持续盯防。 - 时间戳与时区:
timestamps()自动维护;建议库里存 UTC,展示层按用户时区转换,config/app.php的timezone只影响写入。 - 软删除:
use SoftDeletes后删除变更新deleted_at,查询默认自动过滤;需要唯一索引的列(如手机号)要把deleted_at纳入唯一键设计,否则删除后无法重建。 - 集合不是数组:
get()返回 Collection,可链式map/filter/groupBy,但聚合在内存进行;几十万行要分批chunk(500)或惰性lazy()。 - 防注入靠绑定,不靠拼接:永远不要把变量直接拼进
whereRaw;必须拼的用?占位。
你可能会懵的地方
hasOne和belongsTo到底放哪边? 外键在谁的表上,谁就是belongsTo。order_items 表有 order_id,所以 OrderItembelongsTo(Order);Order 没有 order_item_id,所以它hasMany(OrderItem)。with()生成的 SQL 长什么样? 不是 JOIN,而是两条语句:先查主表,再where ... in (主表 id)一次性查关联,PHP 侧按外键装配。多对多时自动带透视表字段。- 迁移跑错了怎么办?
单步回滚
migrate:rollback --step=1;迁移已推生产且 down 会丢数据时,写一个新的补偿迁移而不是回滚。 - 多对多的额外字段(如购买时单价)存哪?
放透视表,定义关系时
->withPivot('price', 'quantity');读写用$model->pivot->price或attach($id, ['price' => 100])。 - Eloquent 和 MyBatis 能共存吗? 技术上可以混用连接;但一个项目里两种数据访问风格会增加认知成本,通常按「常规 CRUD 用 Eloquent,复杂报表用查询构造器/视图」分工即可。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
列表里直接访问 $order->user->name | N+1,50 单变 51 次查询 | with('user') |
| 改旧迁移文件修正字段 | 别人的库不会变更 | 新增迁移 |
| 循环内单条更新库存 | 慢且无法保证一致 | 事务 + 条件 update |
whereRaw("name = '$name'") | SQL 注入 | whereRaw('name = ?', [$name]) |
一次 get() 几十万行再聚合 | 内存打爆 | chunk/lazy 或库内 SUM |
| 事务内先调支付再写库 | 回滚后钱已付 | 先落库(状态处理中)→ 事务外支付 → 回调更新 |
卡点急救
- 关系属性返回 null 但数据存在:检查外键命名(默认
order_id)、所属模型的主键、关系方法有没有误加where;$order->items()(带括号)返回构造器,$order->items才是集合,别搞混。 - 迁移报外键约束失败:被引用的表必须先建(调整迁移时间戳顺序);SQLite 默认外键支持在迁移环境中已开启;字段类型要与引用主键一致(都用 unsignedBigInteger)。
create()什么都没写进去:字段不在$fillable。用$model->getFillable()检查,或改用$model->forceFill()(仅限已过滤的可信数据)。
面试常问
-
什么是 N+1?如何发现和解决? 查 N 条主记录后,遍历中每条再触发一次关联查询,总查询数 1+N。解决:预加载
with()(IN 查询)、loadCount聚合、或 JOIN 手写;发现靠 Debugbar、慢查询日志与开发期preventLazyLoading。能说出「IN 方式预加载 + 监控手段」即为好答案。 -
fillable与guarded的作用,为什么需要它们? 防批量赋值攻击:攻击者在表单里夹带is_admin=1,若代码User::create($request->all())就会提权。白名单fillable显式开放可写字段,是更安全的默认;guarded是黑名单兜底。对应 Jackson 里禁用意外字段反序列化的同类防护。 -
库存扣减如何防超卖? 不依赖「先查后改」(有并发窗口),用条件更新:
UPDATE products SET stock=stock-? WHERE id=? AND stock>=?,根据影响行数判断成败,失败即回滚事务并提示;高并发场景再上 Redis 预扣 + 异步落库或分布式锁。
自测三问
- 订单列表要同时显示下单人姓名和每件商品名称,你会怎么写查询?生成几次 SQL?
- 下单涉及写订单、写明细、扣三处库存,用什么保证一致性?外部支付调用应该放在事务内还是外?
- 上线后要给 orders 表加一个字段,正确的操作步骤是什么?为什么不能直接改原来的建表迁移?
小结一句话
Eloquent 用模型 + 关系 + 迁移把数据操作写得像业务语言,但必须用预加载、事务和条件更新守住性能与一致性两条底线。
延伸
- Eloquent 入门
- 数据库迁移
- 查询构造器
- 数据库事务
- Spring Data JPA 对照
- 下一课:认证与授权——学习登录会话、Sanctum API Token、Gate 与 Policy。