恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
PHP开发框架Laravel数据库操作方法总结
首页
资讯中心
/
PHP开发框架Laravel数据库操作方法总结
PHP开发框架Laravel数据库操作方法总结
发布时间:2026/10/9 10:38:37
前言Laravel 里操作数据库的接口有三套很多人用了一年也说不清它们的边界DB门面下的查询构造器Query Builder、Eloquent模型以及最底层的PDO。这三套不是「新旧替代」关系而是分层关系——Eloquent 内部调用查询构造器查询构造器内部调用 PDO。选哪一套的标准是要不要模型能力类型转换、修改器、事件、关联、软删除。要就用 Eloquent只是取一批统计数字用查询构造器更直接要执行建表、设置会话变量这类非 DML 语句才需要下探到原始语句。第二个常见误解是「用 Eloquent 就一定比查询构造器慢所以性能敏感的地方都该用DB::table」。这说法太粗。真正的性能差异来自具体行为批量update()会不会逐行触发事件、有没有 N1、查出来的数据量有多大。搞清这些行为差异比笼统地背「谁快」有用得多。本文按「接口分层 → 增删改查与事务 → 批量与大数据量 → 调试与排查」四块来总结重点放在不同写法的行为差异上。示例以 Laravel 9/10/11 为基准涉及版本新增 API 的地方会注明。一、三套接口与连接配置?php // 三套接口的典型用法适用于 Laravel 9use App\Models\User;use Illuminate\Support\Facades\DB;// 1. 查询构造器返回 stdClass 对象的集合没有模型能力$rows DB::table(users)-where(status, 1)-get();foreach ($rows as $row) {echo $row-id; // 注意是对象属性不是数组下标}// 2. Eloquent返回模型集合有类型转换、修改器、关联、事件$users User::query()-where(status, 1)-with(roles)-get();// 3. 原始语句适合 DDL、复杂报表 SQL、需要指定返回形态时$stats DB::select(SELECT status, COUNT(*) AS cnt FROM users GROUP BY status);// $stats 是 stdClass 数组$stats[0]-cnt 是字符串MySQL 的 COUNT 返回字符串// 4. 非查询语句DDL、SET 之类用 statement()DB::statement(SET SESSION group_concat_max_len 1000000);连接配置在config/database.php.env只提供值?php // config/database.php 片段读写分离 多连接return [default env(DB_CONNECTION, mysql),connections [mysql [driver mysql,// 读库与写库可以分别配置框架会自动把 SELECT 走 read、写入走 writeread [host [env(DB_HOST_READ, 127.0.0.1)]],write [host [env(DB_HOST_WRITE, 127.0.0.1)]],database env(DB_DATABASE, forge),username env(DB_USERNAME, forge),password env(DB_PASSWORD, ),charset utf8mb4,],logs [driver mysql,host env(LOG_DB_HOST, 127.0.0.1),database env(LOG_DB_DATABASE, logs),username env(LOG_DB_USERNAME, forge),password env(LOG_DB_PASSWORD, ),charset utf8mb4,],],];切换连接有几种方式?php // 适用于 Laravel 9use Illuminate\Support\Facades\DB;// 临时切到另一个连接$logs DB::connection(logs)-table(access_logs)-latest(id)-limit(100)-get();// 模型固定使用某个连接class AccessLog extends \Illuminate\Database\Eloquent\Model{protected $connection logs;}// 运行时给模型换连接$log (new AccessLog())-setConnection(logs);注意读写分离的一个副作用写操作完成后立刻读读请求可能被发到还没同步完的从库上读到旧数据。需要「写完立刻读最新」的场景光靠这套配置并不保证结果——稳妥做法是把这类写入与读取放在同一个事务里完成或显式拿到写连接去执行这次读取。二、增删改查与事务先给一张对照表把三套写法的等价关系理清目标查询构造器Eloquent插入一行DB::table(t)-insert($arr)T::create($arr)插入并拿自增 ID-insertGetId($arr)$m T::create($arr); $m-id批量插入-insert([$a, $b])T::insert([$a, $b])查询-where(...)-get()T::where(...)-get()按主键查询-find($id)T::findOrFail($id)更新-update($arr)$m-update($arr)删除-delete()$m-delete()/T::destroy($ids)计数-count()T::where(...)-count()其中差别最大的不是语法而是副作用写法修改器类型转换模型事件自动updated_at$model-update([...])/save()触发触发触发写入Model::where(...)-update([...])不触发不触发不触发写入DB::table(...)-update([...])不触发不触发不触发不写入这张表解释了几类经典困惑用Post::where(status, draft)-update([title $t])批量改标题发现标题没被修改器处理成小写用DB::table更新后发现updated_at停在旧时间。需要加工或需要事件时只能逐条$model-update()追求批量效率时就在写之前手工把值处理干净。事务有两种写法?php // 适用于 Laravel 9use Illuminate\Support\Facades\DB;// 1. 闭包式自动提交/回滚异常会向上抛出DB::transaction(function () use ($order) {$order-status paid;$order-save();DB::table(order_logs)-insert([order_id $order-id,action pay,created_at now(),updated_at now(),]);}, 3); // 第二个参数是「遇到死锁等错误时的最大重试次数」// 2. 手动式适合事务边界跨方法、需要中途判断的场景DB::beginTransaction();try {// ... 一系列写操作DB::commit();} catch (\Throwable $e) {DB::rollBack();throw $e;}三点必须记住闭包式事务里不要写try/catch把异常吞掉。异常被吞掉后框架会认为一切正常并提交事务你会得到一个「部分成功」的脏状态。闭包里不要做外部副作用发邮件、调第三方接口、写文件。事务可能回滚而这些动作不会。这类操作应该等事务提交后再做。嵌套事务用的是保存点savepoint不是真正独立的事务。内层回滚只回滚到保存点外层如果没有回滚数据仍然是提交的。firstOrCreate()与updateOrCreate()常被当作「原子操作」使用这是个危险的误解?php // ❌ 并发下两个请求可能同时判定「不存在」然后都去插入$user User::firstOrCreate([email $email], [name $name]);// ✅ 正确做法是让数据库来保证唯一性在 email 上建唯一索引// 然后捕获唯一键冲突异常或者用 upsertfirstOrCreate()内部是「先查、查不到再插」两条语句之间没有锁。防重复最终还是要靠唯一索引兜底。三、批量与大数据量处理数据量大时-get()会把所有结果一次性放进内存这是最容易被忽视的瓶颈。四种分批方式的差别方法分批依据支持预加载适用场景chunk(500, $cb)偏移量OFFSET/LIMIT支持只读遍历chunkById(500, $cb)主键游标支持边读边改lazy()/lazyById()内部用游标分批支持需要 LazyCollection 的链式处理cursor()逐行游标不支持极大数据量、纯读取chunk()与chunkById()的区别值得展开。chunk()生成的是带OFFSET的分页查询如果你在遍历过程中修改了排序依据的列比如把status从 1 改成 2而查询条件是status 1后续批次的偏移量就会错位导致部分记录被跳过。chunkById()用主键做游标只要排序依据是主键就不会错位?php // 适用于 Laravel 9边读边改用 chunkByIduse App\Models\User;User::query()-where(status, 1)-chunkById(500, function ($users) {foreach ($users as $user) {// 修改的是 status而分页依据是主键所以不会漏行$user-update([status 2, synced_at now()]);}});?php // lazy() 返回 LazyCollection可以配合预加载适用于大数据量导出User::query()-with(profile)-lazy()-each(function ($user) {// 逐条处理内存占用可控});// cursor() 内存占用最低但正如上表所示它不能预加载关联// 需要在循环里访问关联时会出现 N1 查询foreach (User::query()-cursor() as $user) {// 每次访问 $user-profile 都会单独发一次查询echo $user-profile?-nickname, PHP_EOL;}写入侧的两个批量方法Laravel 8 起可用?php // 1. 忽略冲突的批量插入已存在则跳过不报错DB::table(tags)-insertOrIgnore([[name php],[name laravel],]);// 2. upsert存在则更新、不存在则插入由数据库一次完成DB::table(counters)-upsert([[key_name a, value 1],[key_name b, value 2],],[key_name], // 用来判断冲突的唯一键列[value] // 冲突时需要更新的列);// Eloquent 也有对应方法Laravel 8User::upsert($rows, [email], [name]);upsert()成立的前提是第二参数指定的列上真的有唯一索引或主键。没有的话数据库无法判定「冲突」每一次执行都会插入新行结果就是重复数据越积越多而且不会报任何错。这是upsert最常见的失败方式。四、调试与排查排查数据库问题的顺序永远是先看 SQL再看执行计划最后才改代码。?php // 适用于 Laravel 9use Illuminate\Support\Facades\DB;use Illuminate\Support\Facades\Log;// 1. 全局监听每一条 SQL放在服务提供者的 boot() 里仅开发环境DB::listen(function ($query) {Log::channel(single)-info(sql, [connection $query-connectionName,sql $query-sql,bindings $query-bindings,time_ms $query-time,]);});// 2. 只针对某一段代码收集查询日志DB::connection()-enableQueryLog();$users \App\Models\User::query()-with(roles)-get();$log DB::getQueryLog(); // 数组每项包含 query 与 bindingsDB::connection()-disableQueryLog();// 3. 只想看 SQL 模板和绑定不执行$query \App\Models\User::query()-where(status, 1)-whereIn(id, [1, 2]);echo $query-toSql(), PHP_EOL;print_r($query-getBindings());$query-time的单位是毫秒。用DB::listen()做「开发环境记录慢查询」是个很实用的做法把time超过某个阈值的语句记下来比开慢查询日志更贴近应用代码。N1 是最常见也最容易忽略的性能问题Laravel 8 起提供了自动检测?php // 放在 AppServiceProvider::boot() 里只在非生产环境开启use Illuminate\Database\Eloquent\Model;Model::preventLazyLoading(! app()-isProduction());开启后只要代码在循环里惰性加载了关联框架就会抛出异常强制你去补with()。注意不要在生产环境开启它会改变运行时行为。另外定位慢查询应该交给数据库本身-- 查看某条语句的执行计划关注 type 与 rowsEXPLAIN SELECT id, name FROM shops WHERE latitude BETWEEN 30.0 AND 32.0 ORDER BY id LIMIT 20;type出现ALL意味着全表扫描通常说明筛选列上没有可用的索引或者列上包了函数导致索引失效。常见坑点❌ 把DB::select()的返回当模型用$rows[0]-name能取到但$rows[0]-save()会报方法不存在。✅DB::select()返回的是stdClass数组没有模型能力需要模型就用 Eloquent。❌ 用Model::where(...)-update([...])批量更新期望修改器和模型事件被触发。✅ 查询构造器式的update()不实例化模型修改器、类型转换、事件都不生效但会自动写入updated_at。需要这些行为就得逐条$model-update()。❌ 用DB::table(...)-update()后updated_at没变以为是缓存问题。✅ 查询构造器不会自动维护时间戳需要手工在数组里带上updated_at。❌ 在事务闭包外面先查一次、再进事务写中间被其他请求改动了数据。✅ 需要「读到的就是锁住的」时把读取放进事务并使用lockForUpdate()之类的行锁普通查询不保证这一点。❌ 在DB::transaction()的闭包里try/catch吞掉异常。✅ 异常被吞掉后事务会照常提交产生部分写入。要么让异常冒泡要么显式DB::rollBack()。❌ 用firstOrCreate()当作「绝对不会重复」的保证。✅ 它是「先查后插」并发下不原子。防重复必须靠唯一索引冲突时捕获异常或改用upsert()。❌ 用chunk()遍历时修改了排序依据列结果漏掉一部分记录。✅ 边读边改的场景改用chunkById()/lazyById()让分页依据是主键。❌ 用upsert()时第二参数指定的列上没有唯一索引数据越跑越多。✅ 确认该列或列组合上有唯一索引或主键否则upsert会退化成普通插入。总结场景推荐写法关键注意一般增删改查Eloquent需要类型转换、事件、关联时首选统计、报表、聚合查询构造器返回stdClass无模型能力DDL / 会话设置DB::statement()不返回结果集批量更新查询构造器update()无修改器与事件注意updated_at大数据量遍历chunkById()/lazy()不要用chunk()配合改排序列批量写入insertOrIgnore()/upsert()upsert依赖唯一索引事务DB::transaction()不要吞异常、不要做外部副作用排查DB::listen()/toSql()/EXPLAIN先看 SQL 再改代码结论Laravel 的数据库接口选择本质是在回答「我要不要模型的那套能力」。要就用 Eloquent并且接受它按行处理的成本不要就用查询构造器但必须自己承担时间戳、字段加工和事件这些原本由模型负责的部分。真正会造成线上事故的从来不是「选错层」而是以为切换层不影响行为——批量update静默跳过修改器、upsert在没有唯一索引的表上退化成普通插入、chunk边读边改漏行这三类都属于「代码没错、结果错」务必在写的时候就确认清楚。