从 Laravel 的死锁说起:实体+ID生成器+LocalCache+乐观锁+工作单元,DDD 落地的银弹组合

简介: 阿杰的 Laravel 项目凌晨死锁告警连刷七条,老陈一眼看破:读和写被关进了同一个事务。本文用两笔账拆解显式 save 的封装困境、事务直连的死锁成因,并对照 php-vibe-coding-frame 的真实源码,讲清实体+ID生成器+LocalCache+乐观锁+工作单元这套组合为什么缺一不可。

凌晨一点四十,阿杰的群炸了。

DBA 老周在群里点名:「谁在跑订单库?死锁告警连了七条。」

阿杰翻身爬起来开电脑。监控面板上,Deadlock found when trying to get lock; try restarting transaction 一屏一屏地刷。

他脑子里只有一个念头:我明明用了 DB::transaction() 啊。

第二天早上,老陈端着咖啡路过,扫了一眼他没关的屏幕。

「别盯着日志了,」老陈说,「你那不是并发量的锅,是你把『读』和『写』关进了同一个事务里。」

小迪端着杯子凑过来:「陈哥,死锁不是 MySQL 的脾气不好吗?跟读有啥关系?」

「MySQL 只是个执行者。」老陈拉开椅子坐下,「你先给它画了个圈,它只能在圈里打转。」

「那圈是谁画的?」

「问得很好。今天就把这个圈拆开看看。」

一、第一笔账:显式 save,难用,而且封不住

阿杰的 Laravel 代码长这样:

public function changeAddress(int $order_id, array $address)
{
   
    return DB::transaction(function () use ($order_id, $address) {
   

        $order = Order::findOrFail($order_id);
        $order->address = $address;
        $order->save();                        // ← 手写的

        if ($order->need_invoice) {
   
            $order->invoice()->create([...]);  // ← 又一个手写的
        }

        AuditLog::create(['order_id' => $order_id]);   // ← 还有
    });
}

看起来没问题。问题有三层。

第一层,靠人记。 save() 的调用点散落在几百个 Service 方法里,漏一个,数据就不落库。这种 bug 最恶心的地方在于——它不报错,单元测试也可能过(内存里的对象确实已经改了),只有换一个进程或者换一个实例去读,才会发现数据少了。

第二层,原子性靠你自己保证。 上面那个方法,如果 $order->invoice()->create() 抛异常,$order 的地址已经改完提交了。一次业务操作,数据库里留下半个结果——除非你把每一处都塞进同一个 DB::transaction,而那又回到了第一个问题:靠人记。

第三层,也是真正封不住的地方:你没法把它抽进基类。

想用模板方法?外面包一层 save()——可你根本不知道业务分支改了几个对象、改了哪几个。想用 Model::saved 事件或 Observer?那只是"存完之后发个通知",管不了"存什么"。想用 $model->saveQuietly()?它解决的是副作用,不是时机。

根因是——显式 save 把"持久化的决定权"交给了业务代码。而"存什么、什么时候存、按什么顺序存",本来就该是框架的事。

小迪举手:「那自动提交是怎么做到的?」

老陈打开另一个项目,php-vibe-coding-frame,敲了几行:

// controller/order.php —— 整个闭包已被 unit_of_work() 自动包裹
if_post('/order/address/*', function ($order_id) {
   

    $order = dao('order')->find($order_id);
    $order->address = input_json('address');

    // 没有 save(),没有事务,没有 flush
    // 闭包返回后,框架自己扫一遍变更,统一落库
});

「改了对象,就结束了。」老陈说,「工作单元在闭包结束时,自己扫一遍它跟踪过的所有实体,把脏的挑出来,在一个事务里按顺序落库。」

「它怎么知道谁脏了?」

老陈把 frame/orm_entity.php 拖出来:

// 实体自带两套值:structs = 数据库里的原值,attributes = 内存当前值
public $structs = [];       // 快照
public $attributes = [];    // 现况

final public function just_updated()
{
   
    return $this->attributes != $this->structs;   // 不相等 = 脏了
}

final public function just_new()
{
   
    return self::INIT_VERSION === $this->version; // version=0 就是新建的
}

「加载的时候留一份快照,提交时逐字段比对。attributes != structs 就是脏检测,version = 0 就是新增。这就是自动提交。」

难用 vs 好用、封不住 vs 封得住,差别就在这里:显式 save 是散在每个业务方法里的手工步骤,工作单元是收敛在框架里的一处策略。业务代码不需要知道数据库的存在,这才是分层。

二、第二笔账:为什么"用事务直连"比工作单元更容易死锁

这才是阿杰那天晚上真正踩的坑。

他有两个业务方法:

// 逻辑一:查订单,改库存
public function ship(int $order_id)
{
   
    return DB::transaction(function () use ($order_id) {
   

        $order = Order::find($order_id);                            // 读 A
        $stock = Stock::where('sku', $order->sku)->first();
        $stock->decrement('qty', $order->qty);                      // 写 B —— 锁住了

        // ↓ 后面还要算运费、调支付、发 MQ……锁一直不放
        $this->call_payment_gateway($order);
        Queue::push(new SendSmsJob($order->user_id));
    });
}

// 逻辑二:查库存,回头改订单状态
public function allocate(string $sku)
{
   
    return DB::transaction(function () use ($sku) {
   

        $stock = Stock::where('sku', $sku)->first();                // 读 B
        $order = Order::where('sku', $sku)->where('status', 'pending')->first();
        $order->status = 'allocated';
        $order->save();                                             // 写 A —— 锁住了

        Queue::push(new SendNotifyJob($order->user_id));
    });
}

单看每个方法都没问题,并发起来就是灾难:

T1: 读 A —→ 写 B(锁住 B,事务不结束,锁不放)
T2: 读 B —→ 写 A(锁住 A,回头等 B)
T1: 等 A ……
T2: 等 B ……            → 死锁

老陈在白板上圈了两个词。

锁窗口。T1 从 decrement() 那一刻锁住 B 行,一直到方法返回、事务提交,锁才释放。这段时间它在算运费、调支付、发 MQ——锁的持有时间和你的业务代码长度成正比。你的方法跑 800ms,锁就压 800ms。两个长事务一交叉,环就成了。」

「那工作单元为什么不会?」

「因为工作单元里,读不进事务。」

这句话值得停一下。老陈翻到 frame/database_mysql.php

// 查询走 read 连接(读写分离)
function db_query($sql_template, array $binds = [], $config_key = 'default')
{
   
    return _mysql_database_closure($config_key, 'read', function ($connection) use (...) {
   
        ...
    });
}

// 事务走 write 连接
function db_transaction(closure $action, $config_key = 'default')
{
   
    db_force_type_write(true);
    return _mysql_database_closure($config_key, 'write', function ($connection) use ($action) {
   
        $began = $connection->beginTransaction();
        ...
    });
}

「看清楚了?读在 read 连接上,事务开在 write 连接上——两个连接。 你的 SELECT 从头到尾就没进过那个事务。」

老陈继续往下翻,把工作单元的主循环摊开:

function unit_of_work(Closure $action)
{
   
    local_cache_delete_all();

    $res = $action();                 // ← 业务逻辑:查询、计算、调 RPC,全程无事务
    $entities = local_cache_get_all();

    $sqls = [];
    foreach ($entities as $entity) {
     // ← 只生成 SQL,不执行
        if ($entity->just_new()) {
   
            $sqls[] = $dao->dump_insert_sql($entity);
        } elseif ($entity->just_updated()) {
   
            $sqls[] = $dao->dump_update_sql($entity);
        }
    }

    if ($sqls) {
   
        if (count($sqls) > 1) {
   
            db_transaction(function () use ($sqls, $db_config_key) {
   
                foreach ($sqls as $sql) {
   
                    _unit_of_work_write($sql['sql_template'], $sql['binds'], $db_config_key);
                }
            }, $db_config_key);
        } else {
   
            // 只有一条写语句?连事务都不开
            $sql = reset($sqls);
            _unit_of_work_write($sql['sql_template'], $sql['binds'], $db_config_key);
        }
    }

    return $res;
}

「把 $action()db_transaction(...) 的位置看清楚。」老陈用笔尖点了两下,「整个业务方法在事务外面。事务里只有几条 UPDATE。

于是 T1 的形状变成了:

[读 A、读 B、算运费、调支付、发 MQ —— 全程无事务,不持锁]

   ↓ 最后一刻才开事务
[UPDATE stock ... ; UPDATE orders ...   ← 只有 2 条语句,约 3ms]
   ↓ commit

「锁窗口从几百毫秒压到几毫秒。而且——」

老陈翻到 orm_entity.phpdump_update_sql

final public function dump_update_sql($entity)
{
   
    $binds[':id']          = $entity->id;
    $binds[':old_version'] = $entity->version;          // ← 拿读到的旧版本号做条件

    foreach ($this->get_dirty($entity) as $column => $value) {
   
        $update[] = "$column = :$column";
        $binds[":$column"] = $value;
    }

    return [
        'sql_template' => 'update '.$this->table_name
            .' set '.implode(', ', $update)
            .' where id = :id and version = :old_version',   // ← 乐观锁
        'binds' => $binds,
    ];
}

而执行它的地方,只有六行:

function _unit_of_work_write($sql_template, array $binds = [], $config_key = 'default')
{
   
    $row_count = db_write($sql_template, $binds, $config_key);

    otherwise(
        $row_count === 1,                                  // 影响行数必须是 1
        'data in unit of work is expired',                 // 否则:你的实体过期了
        'exception',
        UNITOFWORK_DEFAULT_ERROR_CODE
    );
}

乐观锁。带 version 条件更新,影响行数不是 1 就抛异常——它不排队等锁,所以它不参与等待环。」

「死锁的四个必要条件里,最后那个『循环等待』根本形不成。因为冲突在这里不是"等",是"失败"。」

小迪盯着那行 where id = :id and version = :old_version 看了半天:「所以……死锁不是 MySQL 脾气不好,是我们把锁窗口拉得太长了?」

「对。数据库锁的是行,工作单元锁的是你的设计。前者会死锁,后者不会。

「那要是真撞上了呢?」

「那就重试整个请求。」老陈说,「数据已经是新的了,重读一遍再改——比让两个事务互相瞪着强。」

三、银弹:五个零件,少一个都不成立

「那这套东西到底怎么拼起来的?」阿杰问。

老陈在白板上画了五个方块:实体 + ID 生成器 + LocalCache + 乐观锁 + 工作单元

「这五个是一套的。单拿一个出来,都只是另一个麻烦。」

1. 实体(Entity)

以 ID 定义"我是谁",状态可变、行为内聚。在 php-vibe-coding-frame 里,实体基类内置五个系统字段,业务字段只声明自己关心的:

class order extends entity
{
   
    public $structs = [
        'user_id' => 0,
        'amount'  => 0,
        'status'  => 'pending',
    ];

    public static function create(int $user_id, float $amount): order
    {
   
        $order = parent::init();
        $order->user_id = $user_id;
        $order->amount  = $amount;
        return $order;
    }
}

id / version / create_time / update_time / delete_time 五个字段由基类自动管理。它是工作单元能"跟踪"的前提——只有实体才有同一性。值对象没有 ID,你无法说清"这个和那个是不是同一个",也就无从跟踪。

2. ID 生成器

在应用层生成,不等数据库自增回填。这个框架用的是 Redis INCR 批量取号:

// 每次向 Redis 要一段号,本地发完再要下一段,省网络往返
function generate_id($mark = 'idgenter')
{
   
    ...
    $step_last_id = cache_increment($mark.'_last_id', $step, 0, 'idgenter');
    $now_id = $step_last_id - $step;
    return $now_id += 1;
}

// 实体初始化时就拿到 id —— 还没入库
protected static function init()
{
   
    $static = new static();
    $static->attributes = $static->structs;
    $static->id = self::generate_id();        // ← 入库前就有身份
    $static->version = self::INIT_VERSION;    // 0,就是新增
    $static->create_time = $static->update_time = datetime();

    local_cache_set($static);                 // ← 立刻登记进本地缓存
    return $static;
}

它解决两个具体问题:

  • 对象在入库之前就有身份 → 工作单元可以拿 ID 当 key 去重、去跟踪
  • 内存里可以先建关联:订单先有 ID,明细再挂上去,最后统一落库。不用"先插主表拿回 ID 再插子表"

没有它,工作单元只能处理"已存在"的实体,新增的根本管不了——version = 0 这个判断也就无从谈起。

3. LocalCache(请求级身份映射)

同一个请求内,同一行数据只对应一个对象实例

// 本地缓存 —— 请求级别的 Identity Map,同一请求内相同实体只从数据库加载一次
function _local_cache_key($entity_type, $id)
{
   
    return $entity_type.'_'.$id;      // 类名 + 主键
}

function local_cache_get($entity_type, $id)
{
   
    $cached = _local_cache();
    $key = _local_cache_key($entity_type, $id);

    if (isset($cached[$key])) {
   
        return $cached[$key];         // 命中就返回同一个对象
    }

    return;
}

DAO 每次读数据库前,都先 local_cache_get 查一遍;命中就直接返回同一个对象。所以下面这段不会有问题:

$a = dao('order')->find(1);   // 第一次查库
$b = dao('order')->find(1);   // 命中 LocalCache,拿到的是同一个对象

$a->status = 'paid';
// $b->status 也一起变了 —— 因为它俩就是同一个对象

它是工作单元的地基。没有它,同一行会被加载成两个副本,工作单元 flush 时不知道该听谁的,后写的静默覆盖先写的——这就是经典的丢更新。

4. 乐观锁

version 字段,提交时校验。它是长读窗口的必然补偿——正因为读不进事务,你读到的是"某个时刻"的数据,从读到写之间可能有别人改了同一行。传统事务靠行锁挡住,工作单元靠 version 检测。

而且它的价值不只是"防冲突":它让失败变得明确

Laravel 里 $order->save() 生成的是 update orders set ... where id = 1——无条件覆盖,静默丢更新。框架里生成的是 ... where id = :id and version = :old_version,影响 0 行直接报「data in unit of work is expired」。

前者是"我不知道我覆盖了别人",后者是"我知道我该重试"。这才是并发控制该有的语义。

5. 工作单元(Unit of Work)

它是那根把前四个零件串起来的轴:登记加载过的实体 → 脏检测 → 提交时按顺序 flush 到一个短事务。

而且在这个框架里,它是零配置的——路由闭包在 HTTP 入口处就被自动包住了:

// public/index.php —— 用 if_verify 钩子把每个路由闭包套进工作单元
if_verify(function ($action, $args) {
   

    return unit_of_work(function () use ($action, $args) {
   

        $data = call_user_func_array($action, $args);   // ← 你的业务闭包在这里面跑
        ...
    });
});

所以开发者写业务代码时,脑子里根本不需要有"持久化"这三个字:

if_post('/order/ship/*', function ($order_id) {
   
    $order = dao('order')->find($order_id);
    $order->status = 'shipped';                    // 改完就完事

    $stock = dao('stock')->find_by_column(['sku' => $order->sku]);
    $stock->qty = $stock->qty - $order->qty;       // 改完就完事
});

// 框架接着做:扫 local_cache → 生成 2 条 UPDATE → 一个短事务里提交 → 校验影响行数

链条闭合起来是这样:

零件 提供什么 缺了会怎样
实体 同一性 + 可跟踪的状态 无从判断"改了什么"
ID 生成器 入库前的身份 新增实体管不了,version = 0 也无从判断
LocalCache 请求内唯一实例 副本互相覆盖,静默丢更新
乐观锁 冲突检测 + 明确失败 长读窗口必然丢更新
工作单元 统一 flush + 短事务 前四个零件各自为战,还得手写 save

再和 Laravel 的写法并排看一眼,差异就非常直观:

关注点 Laravel 默认 php-vibe-coding-frame
何时落库 手动 save(),散落在 Service 里 框架在闭包结束时自动 flush
跨聚合原子性 手动套 DB::transaction,靠人记 工作单元天然就是一个事务边界
事务范围 整个业务方法,含 RPC 和 MQ 只有最后那几条写 SQL
读操作 和写在同一个事务里 走独立 read 连接,不进事务
并发冲突 save() 无条件覆盖,静默丢更新 where version = :old + 影响行数校验
主键生成 数据库自增,插入后才拿到 Redis 批量取号,入库前就有 ID
同一行的多个引用 多个对象副本,各自为政 LocalCache 保证请求内唯一实例

五个一起,才叫银弹;单拿一个出来,都只是"另一个麻烦"。

收尾

那天晚上阿杰翻着框架源码,在工位白板上补了一行:

读不进事务,写只占毫秒,冲突交给 version —— 死锁的前提,是你自己造出来的。

老陈路过,丢下一句:

显式 save 是把持久化的决定权交给业务代码,工作单元是把它收回来交给框架。凡是靠人记的,迟早会忘。

如果你现在也在用 DB::transaction() 包着整个业务方法,不妨先做一件事:把读操作挪到事务外面,把写操作攒到最后。光这一步,告警就会少一半。

想直接看这套实现,代码在这里:

git clone https://your-git-host/php-vibe-coding-frame.git
cd php-vibe-coding-frame
sh project/tool/start_development_server.sh
php public/cli.php migrate

写一个 if_post 闭包,改个实体属性,然后看框架怎么替你收尾——五分钟后你就会明白,「不用 save」不是偷懒,是把决定权放回了它该在的地方。

目录
相关文章
|
6天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
1750 9
|
10天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1637 2
|
11天前
|
网络协议 Linux iOS开发
【2026实测】Wireshark下载+安装+汉化+使用教程(图文版,巨详细)
Wireshark 是一款免费开源的网络协议分析工具,可实时捕获、解析并可视化数据包,助你诊断网络故障、分析通信协议(如HTTP、DNS、TCP等)。支持Windows/macOS/Linux,含中文界面,新手入门便捷。(239字)
|
7天前
|
SQL 人工智能 前端开发
QoderWake 1.0 正式发布:从桌面里的 Agent,到工作现场的数字员工
QoderWake v1.0正式发布:企业级数字员工团队平台。支持“一句话建岗”,预置10类特训岗位;Waker常驻钉钉/飞书群,@即响应、自动协作、跨任务记忆;具备定时/事件/API多触发方式与统一任务看板;已沉淀27.6万条记忆、12.3万项技能,助力组织实现人机协同增效。
770 2
|
5天前
|
缓存 测试技术 API
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)
DeepSeek V4.1 Flash 内测不用申请,base_url 不变、改个模型名就能调,9/10 到期。本文讲清接入、计费限流与多模态注意点。
769 0
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)
|
19天前
|
人工智能 自然语言处理 安全
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
本文聚焦阿里云2026年推出的三款自研AI办公产品,清晰拆解千问办公、Qoder Teams、Qoder CN的差异化定位与能力边界:千问办公主打职场全场景提效,支持自然语言指令一键完成PPT生成、数据分析等高频办公任务;Qoder Teams面向程序员团队,深度整合AI代码生成、团队协同与企业知识库能力;Qoder CN则专为金融、政务等强合规场景打造,实现数据不出境与VPC私有化部署。文章同步给出分场景选型指南与最新活动定价,帮助不同类型的企业按需组合产品,实现业务岗、研发岗与强合规场景的AI能力全覆盖。
3935 5
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
|
10天前
|
人工智能 自然语言处理 安全
阿里云AI数智鉴密:AI 生成内容如何拿到一张"防篡改的身份证"
隐形水印 + C2PA签名:让AI生成内容“持证上岗”。
1151 0
|
12天前
|
缓存 数据可视化 开发工具
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
DeepSeek Harness 的更新分两层:本体更新(npx 自动最新、npm update -g、源码 git pull)与插件更新(插件市场点更新、命令行覆盖安装)。本文按「准备 → 更新本体 → 更新插件 → 更新后检查」四步走,覆盖新手常见疑问。
1403 1
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式