从 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.php 的 dump_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」不是偷懒,是把决定权放回了它该在的地方。

目录
相关文章
|
22天前
|
存储 缓存 NoSQL
Redis 只是个缓存:老陈让我关掉 AOF,也把 Hash 和 RedisJSON 全删了
阿杰给 Redis 开了 AOF、又把业务数据塞进 Hash,直到 P99 从 80ms 飙到 4 秒。老陈点破:你在拿缓存当数据库用。本文讲清 Redis 该只用哪一部分、缓存定位、二级缓存防击穿,以及 hash 分片扩容。
98 4
|
15天前
|
搜索推荐 测试技术 开发工具
我让大模型「写作文」写了 47 版正则,直到老陈说:你在让语文老师做判断题
周五晚上十一点,第 47 版正则还是错的。这不是提示词问题——我一直在让一个「写作文的模型」去做判断题。记录我换成 TypeSafe Jev(System One 模型)重写工单分诊的完整过程:三类问题原语怎么选、问题为什么要拆到原子级、一次调用问 13 个问题便宜 12 倍的实测数据,以及 confidence 门控与中文准确率等四个踩坑心得。
64 0
|
24天前
|
小程序 BI 调度
上门家政预约系统源码怎么搭建?小程序、APP和后台需要哪些功能
随着家政行业数字化发展,上门家政预约系统逐渐成为连接用户、家政人员与平台的重要工具。本文从家政小程序、APP和后台管理三个方面,详细介绍在线预约、订单管理、人员排班、智能派单、在线支付、售后评价及数据统计等核心功能。
|
24天前
|
人工智能 自然语言处理 数据可视化
阿里云万小智实操:真正的AI建站,一句话生成网站,包括万小智计费、使用及网站上线流程
阿里云万小智2.0是面向中小企业的AI建站工具,支持自然语言一句话生成含前后端、数据库的完整网站,5–10分钟即可预览、编辑并一键上线,适用于官网、资讯、预约等场景。(239字)
92 2
|
29天前
|
人工智能 前端开发 JavaScript
我终于能用 GPT-6 了!实测一波,Codex 要杀疯了?
GPT-6 Astra 实战测评,4 大项目实战,覆盖 AI 编程能力和 Computer Use 电脑操作能力,看看号称全世界最智能的模型,在 Codex 中的使用体验到底如何?和 Claude Fable 5 比起来怎么样?
293 1
|
1月前
|
人工智能 NoSQL 定位技术
Palantir 爆火后,为什么 AI Agent 落地绕不开「本体论」?
Palantir 爆火后,「本体论」被反复提及,却常被误读成替代信息化、甚至等同于 Agent。本文用「阿杰+老陈」双视角讲清:本体论本质是连接 Agent 与企业知识库/业务系统的一座桥,对 IT 人而言,它就是 DDD 及业务规则的沉淀方法论。还剖析了 Palantir 为何自带本体论实施工具,以及国内复刻者稀少时,企业 IT 如何用自己的方式吃透核心概念落地。
227 0
|
4月前
|
运维 Java Nacos
Spring Cloud Alibaba + MSE Nacos:微服务注册发现与配置中心实战
Nacos 是 Spring Cloud Alibaba 生态的核心组件,承担服务注册发现与配置中心双重职责。阿里云 MSE(微服务引擎)提供了 Nacos 的全托管版本,免运维、高可用、与企业版功能增强。本文从电商微服务场景出发,实战演示 Nacos 自建集群 vs MSE Nacos 托管两种方案:服务注册发现、配置中心热更新、命名空间隔离、灰度发布,并给出详细的成本对比和选型建议。
Spring Cloud Alibaba + MSE Nacos:微服务注册发现与配置中心实战
|
22天前
|
人工智能 BI API
企业AI办公新方案|QwenWork千问办公深度实战:Qwen3.8基座六大核心能力、API调用与企业计费选型完整指南
大模型赋能办公已经迈入全新发展阶段,传统AI工具大多局限在问答对话、文档摘要、简单文案改写这类碎片化单点任务。在处理复杂真实业务时,使用者往往需要在多款软件之间来回切换,手动复制粘贴各类中间输出结果,很难形成端到端完整业务闭环。很多企业想要落地AI办公自动化,就必须投入大量研发人力做工具整合,开发门槛高,普通业务人员很难直接上手使用。
243 1
|
22天前
|
缓存 人工智能 自然语言处理
阿里云千问大模型Qwen3.8-Max介绍:2.4 万亿参数模型,编程与办公能力全面跃升,限时4折起
本文全面拆解阿里云千问旗舰模型Qwen3.8-Max,围绕其2.4万亿参数MoE架构核心特性展开,梳理百万级上下文、原生多模态理解、长程自主规划等核心能力,覆盖六大全球部署区域的功能差异与完整计费体系,重点突出其在编程、办公及法律、金融等专业场景的优势,同步配套新用户免费额度、夜间4折等专属优惠,为开发者提供清晰的选型参考与落地优化指引。

热门文章

最新文章