凌晨一点四十,阿杰的群炸了。
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」不是偷懒,是把决定权放回了它该在的地方。