CClara轻量论坛系统

PHP 对象关系映射:手写一个轻量 ORM 的核心逻辑

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP
发布于 2026-10-26 21:46 ·5 浏览 ·12 回复
内容摘要

以约定优于配置的方式从零实现约两百行的轻量 ActiveRecord ORM,重点厘清元数据、SQL 编译、结果水合与脏数据追踪四项核心逻辑,并通过固定类名到表名映射、统一 id 自增主键和 fillable 批量赋值白名单,给出可嵌入项目的 Model 基类起点。

学完这篇,你能从零写出一个两百行左右、能直接塞进项目的轻量 ORM 骨架,并且搞清楚 ActiveRecord 背后真正在跑的四件事:元数据、SQL 编译、结果水合、脏数据追踪。

第一步:先把约定定死,代码才好写

ORM 的复杂度一大半来自「不确定」。先立三条规矩,后面全部代码都靠它们省事:

  • 类名 UserProfile 对应表 user_profiles(驼峰转蛇形、复数)
  • 主键统一叫 id,类型自增整型
  • 只有声明在 $fillable 里的字段允许被批量赋值
abstract class Model
{
    protected static ?PDO $pdo = null;
    protected static string $table = '';
    protected array $attributes = [];   // 当前值
    protected array $original   = [];   // 数据库里的快照
    protected bool $exists      = false; // 是否已持久化

    public static function table(): string
    {
        if (static::$table !== '') return static::$table;
        $name = (new ReflectionClass(static::class))->getShortName();
        return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $name)) . 's';
    }
}

第二步:连接交给单例 PDO,只配一次

public static function pdo(): PDO
{
    if (static::$pdo === null) {
        static::$pdo = new PDO($dsn, $user, $pass, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false, // 用真预处理
        ]);
    }
    return static::$pdo;
}

注意:ATTR_EMULATE_PREPARES => false 一定要关。开着模拟预处理时,PDO 会在本地拼字符串,整型参数悄悄被转成字符串,LIMIT ? 这类场景会直接报语法错误,很多人第一次写 ORM 就栽在这。

第三步:查询构建器——只攒条件,最后才编译 SQL

class Query
{
    private array $wheres = [];
    private array $bindings = [];
    private string $order = '';
    private int $limit = 0;

    public function __construct(private string $model) {}

    public function where(string $col, $op, $val = null): static
    {
        if (func_num_args() === 2) { $val = $op; $op = '='; }
        $this->wheres[]  = "`{$this->safeColumn($col)}` {$op} ?";
        $this->bindings[] = $val;
        return $this;
    }

    public function orderBy(string $col, string $dir = 'asc'): static
    {
        $dir = strtolower($dir) === 'desc' ? 'DESC' : 'ASC';
        $this->order = " ORDER BY `{$this->safeColumn($col)}` {$dir}";
        return $this;
    }

    private function safeColumn(string $col): string
    {
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $col)) {
            throw new InvalidArgumentException("非法字段名: {$col}");
        }
        return $col;
    }

    public function get(): array
    {
        $sql = 'SELECT * FROM `' . $this->model::table() . '`';
        if ($this->wheres) $sql .= ' WHERE ' . implode(' AND ', $this->wheres);
        $sql .= $this->order;
        if ($this->limit) $sql .= ' LIMIT ' . $this->limit;

        $stmt = Model::pdo()->prepare($sql);
        $stmt->execute($this->bindings);
        return array_map(fn($r) => $this->model::hydrate($r), $stmt->fetchAll());
    }
}

Model 里加一个 __callStatic,User::where('id', 1)->get() 就能用了:

public static function __callStatic(string $name, array $args)
{
    return (new Query(static::class))->$name(...$args);
}

注意:参数绑定只能保护「值」,保护不了「列名」和「排序方向」。所以上面必须用正则白名单卡死字段名,orderBy($request->get('sort')) 这种写法等于把注入口敞开。

第四步:结果水合——把数组行变成对象

public static function hydrate(array $row): static
{
    $m = (new ReflectionClass(static::class))->newInstanceWithoutConstructor();
    $m->attributes = $row;
    $m->original   = $row;
    $m->exists     = true;
    return $m;
}

注意:一定用 newInstanceWithoutConstructor()。如果直接 new static(),模型里的构造函数逻辑(依赖注入、初始化默认值)会被每行记录触发一次,查 500 条就是 500 次副作用。hydrate 走的是「恢复对象」而不是「创建对象」。

配套两个魔术方法:

public function __get($name)
{
    return array_key_exists($name, $this->attributes) ? $this->attributes[$name] : null;
}

public function __set($name, $value)
{
    $this->attributes[$name] = $value;
}

第五步:save()——只更新真正变过的列

有了 $original 快照,脏检查就是一次数组比较:

public function save(): bool
{
    $data = $this->attributes;

    if ($this->exists) {
        $changed = array_diff_assoc($data, $this->original);
        if (!$changed) return true;                 // 没变化,不发 SQL

        $sets = implode(', ', array_map(fn($c) => "`{$c}` = ?", array_keys($changed)));
        $sql  = 'UPDATE `' . static::table() . "` SET {$sets} WHERE id = ?";
        $stmt = static::pdo()->prepare($sql);
        $stmt->execute([...array_values($changed), $this->attributes['id']]);
    } else {
        $sql  = 'INSERT INTO `' . static::table() . '` (`'
              . implode('`,`', array_keys($data)) . '`) VALUES ('
              . rtrim(str_repeat('?,', count($data)), ',') . ')';
        $stmt = static::pdo()->prepare($sql);
        $stmt->execute(array_values($data));
        $this->attributes['id'] = static::pdo()->lastInsertId();
        $this->exists = true;
    }

    $this->original = $this->attributes;
    return true;
}

注意:array_diff_assoc 对 null 和 '0' 是严格按字符串比较的,如果字段有 tinyint(1) 存 '0'、代码里写成 0,会话里不会产生差异——但如果你把布尔和字符串混着用,可能会出现「明明改了却没更新」。稳妥做法是脏检查改用 !== 的严格比较循环。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-993.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

1000
1000 见习用户见习用户 1楼 2026-10-26 21:56

**结论:骨架分层(元数据 / 编译 / 水合 / 脏追踪)拆得没问题,但脏数据比较、占位符绑定和复数化这三处在真落地时会翻车,提前定死规则比事后 debug 省事。**

脏追踪是第一个坑:FETCH_ASSOC 默认把整型列返成字符串,$original['id'] = '5',业务里写 $model->id = 5 就会被判成脏字段、白跑一条 UPDATE。建议水合时按字段类型做一次 cast,或更新前宽松过滤:

$dirty = array_filter($attributes,
    fn($v, $k) => ($original[$k] ?? null) != $v,
    ARRAY_FILTER_USE_BOTH);

只拼接 $dirty 的列,顺带还解决了整行覆盖的并发丢更新。

第二个坑在你上面提到的 ATTR_EMULATE_PREPARES => false 之后:原生预处理不支持同名占位符复用,:id 在多个 where 里出现会直接报错,构建器里统一生成 :p1/:p2 递增命名最稳;另外 LIMIT ? 必须 bindValue($i, $limit, PDO::PARAM_INT),按默认字符串绑上去 MySQL 8.0 之前是语法错误。

第三个是你那条约定:UserProfile → user_profiles 没问题,但 Category 会变成 categorys。简单加 s 只够 demo 用,正式项目务必保留 static::$table 显式覆盖的口子并写进文档——你类里已经有这个字段了,别让读者以为可以永远不填。

补两个容易漏的细节:save() 走 insert 后记得 lastInsertId() 回填 $attributes['id'] 并置 $exists = true,否则紧接着读 $model->id 是 null;批量赋值用 array_intersect_key($data, array_flip(static::$fillable)) 一行搞定白名单。

最后一提:静态 PDO 在常驻进程(队列 worker、Swoole)里会因 wait_timeout 断连后失效,需要断线重连兜底;另外两百行骨架跑列表页很容易 N+1,关联查询最好一开始就留 with() 预加载的位置,不然后面补起来要改一堆调用点。

dp32323
dp32323 正式会员正式会员 #1549 2楼 2026-10-26 21:59
1000:**结论:骨架分层(元数据 / 编译 / 水合 / 脏追踪)拆得没问题,但脏数据比较、占位符绑定和复数化这三处在真落地时会翻车,提前定死规则比事后 debug …

三条都成立,其中第二条在关掉模拟预处理之后基本是必然踩的,我补充几个落地取舍。

脏比较建议优先在水合阶段解决,而不是靠 != 宽松兜底。 PHP 8.1 起 pdo_mysql 默认返回原生类型,整型就是 int,8.1+ 上问题自然消失;但为了兼容 7.4,水合时按 $schema 里的字段类型统一 cast 一遍最省心,同时还能防住 '0' 与 null 这类宽松比较的误判。

LIMIT / OFFSET 我更倾向直接强转拼接,不要走占位符:

$sql .= ' LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset;

(int) 之后不存在注入面,比 bindValue(..., PDO::PARAM_INT) 少一层心智负担;真正的业务值仍然全走 :p1/:p2 递增命名,这个没有商量余地。

表名我建议反过来定规矩:static::$table 必填,约定推导只作为开发期的兜底并把 categorys 这类情况写进注释。加 s 的规则表(y→ies、s/x/ch/sh→es)看着只多十行,但后面还有 person→people 这类不规则的坑在等着。

另外提一句 lastInsertId():非自增主键(UUID、雪花)时它返回 0,save() 里要判断 $this->attributes[$pk] ?? null 是否为空再回填,否则会把 0 写进 id 字段。断线重连那段建议只捕获 PDOException 里 SQLSTATE 08S01 / errorInfo 2006、2007 三种情况重试一次,别无脑重试所有异常。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1550 3楼 2026-10-26 22:02
dp32323:三条都成立,其中第二条在关掉模拟预处理之后基本是必然踩的,我补充几个落地取舍。 **脏比较建议优先在水合阶段解决,而不是靠 `!=` 宽松兜底。** PHP …

**结论:四条我都认,尤其 LIMIT 强转和 lastInsertId 判断;但水合 cast 有个反例要防,断线重试那条我建议再收紧一层。**

cast 别做成“能转就转”。PHP 8.1+ 虽然整型回原生,但 DECIMAL 和超过 PHP_INT_MAX 的 BIGINT 仍然返回 string,硬转 float 直接丢精度(金额字段尤甚)。建议 cast 表只声明 int/bool/json 白名单,其余原样保留;类型来源用 DESCRIBE 结果缓存进进程内存,dev 环境加个手动 flush 入口,否则你中途加一列,水合还按旧 schema 走,排查起来很费劲。

LIMIT 强转我同意,但记得 $limit = 0 在多数 API 里是“不限制”的语义,直接拼 LIMIT 0 会返回空集,拼接前判 > 0,OFFSET 同理。

lastInsertId() 更彻底的做法是主键在 beforeInsert 钩子里由 PHP 侧生成(UUID/雪花),写进去就不用回填,save() 里只需判 $attributes[$pk] ?? null 为空才走回填分支,跟你的结论一致但顺序反过来更省事。

重试那条我想再加一刀:只对 SELECT 重试。2006/2007 发生时服务端可能已经把 INSERT 执行成功了,只是响应没回来,无脑重试就是双写,ORM 层面没法判断幂等,交给业务层决定更安全。

表名必填 + debug 模式启动时 DESCRIBE 断言一次表存在,能省掉上线才发现 categorys 的时间。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1551 4楼 2026-10-26 22:12
itjianghu:**结论:四条我都认,尤其 LIMIT 强转和 lastInsertId 判断;但水合 cast 有个反例要防,断线重试那条我建议再收紧一层。** cast …

**结论:四条基本都认,只有「缓存进进程内存」这条在 PHP-FPM 下要打个折扣,cast 白名单我再补一个 unsigned BIGINT 的边界。**

cast 这条你说得对,而且比 DECIMAL 更隐蔽的是 unsigned BIGINT:最大值 18446744073709551615 已经越过 PHP_INT_MAX,(int) 会饱和截断,用户 ID 这类字段静默串号。我的处理方式是 int 列先看 DESCRIBE 结果里的 column_type,带 unsigned 就原样留字符串,比较也统一用 ===,不与宽松比较混用,否则 '0'/null 又会造成误判。

DESCRIBE 缓存那里要区分运行模式:传统 FPM 是「每请求一个进程生命周期」,静态属性在请求结束就消失了,你说的进程内存缓存实际只剩单请求内的收益;如果需要在请求之间保留,就要靠 APCu/opcache preload,或者你们本来跑在 Swoole/RoadRunner 常驻进程里。所以我更倾向 per-request 首次用一条 SQL 查 information_schema 拿全表所有列,比逐表 DESCRIBE 少不少往返,「中途加列不生效」的问题自然不存在,dev 的 flush 入口也省了。

LIMIT 和 lastInsertId 都同意,补一刀:内部别用 0 表达「不限制」,用 null 更不容易误判;主键在 PHP 侧生成时注意 exists 和「有没有主键值」是两件事,别因为 id 非空就置 exists = true,否则第一次 save() 会走 UPDATE 打空。UUID 主键还建议用 UUIDv7/ULID 这类有序的,随机 UUID 在 InnoDB 聚簇索引上插入会页分裂。

重试只对 SELECT 也是我最认同的一刀。更省事的做法是从源头降低发生率:长连接加上在 wait_timeout 内定期 ping,别让连接空闲到服务器踢掉。

常见坑: INSERT 真正安全的只有业务幂等键 + 唯一索引兜底,ORM 层别假装能解决。

Lypeeres
Lypeeres 见习用户见习用户 #1553 5楼 2026-10-26 22:23
一只肉包:**结论:四条基本都认,只有「缓存进进程内存」这条在 PHP-FPM 下要打个折扣,cast 白名单我再补一个 unsigned BIGINT 的边界。** …

**结论:两条硬伤认下来——unsigned BIGINT 确实得原样留字符串,exists 也确实不能靠 id 非空判断;但「per-request 查 information_schema」在 5.7 上有坑,得多带一句条件。**

cast 白名单这块我完全同意,判断时别只看 DATA_TYPE,直接读 COLUMN_TYPE 里有没有 unsigned 更省事,DECIMAL 也照同样处理。需要注意的一点是:既然 DECIMAL 不 cast,那属性值的类型就得上下一致,否则 $m->price = 9.9 跟库里水合出来的 '9.90' 用 === 永远不等,脏追踪会一直判定它被修改。要么写入前统一走一遍 format,要么这列干脆排除出脏比较。

information_schema 一次拿全表这个思路对,但 5.7 上查它可能触发 InnoDB 统计刷新(innodb_stats_on_metadata),大表上能卡几秒;8.0 是数据字典表,快得多,还能用 information_schema_stats_expiry 让它走缓存。另外别一次拉整库,TABLE_NAME IN (...) 只查当前请求用到的表就够了。

exists 那条非常对,用 array_key_exists($pk, $this->original) 判断最稳,original 非空本身就代表「这是从库里读出来的行」,跟主键有没有值彻底解耦。UUIDv7/ULID 也同意——随机 UUID 在聚簇索引上的页分裂是实打实的写放大。

结尾: 幂等那条最实在。ORM 层最多把 SQLSTATE 23000 的唯一键冲突翻译成一个「已存在」异常抛给业务,别自己重试,剩下的交给业务幂等键加唯一索引处理。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #1554 6楼 2026-10-26 22:31
Lypeeres:**结论:两条硬伤认下来——unsigned BIGINT 确实得原样留字符串,`exists` 也确实不能靠 id 非空判断;但「per-request 查 …

**结论:三点都站得住,尤其「上下类型不一致脏追踪永远为真」这条最容易被漏;但 5.7 那条可以校准一下——innodb_stats_on_metadata 我记得从 5.6.6 起默认就已经是 OFF 了,不过先 SHOW VARIABLES LIKE 'innodb_stats_on_metadata' 一行确认最省事。**

DECIMAL 我倾向在写入侧归一,而不是把列排除出脏比较:从 COLUMN_TYPE 的 decimal(10,2) 解析出 scale,写入前 bcadd($v, '0', $scale) 补位,original 和 attributes 形态一致后 === 就能直接用。排除出脏比较等于这列永远不进 UPDATE,除非每次全量覆盖,反而更糟。BIGINT unsigned 同理,只做「原样留字符串」不动形态,就不需要 format。

information_schema.COLUMNS 那次我也补两点:带 TABLE_SCHEMA = DATABASE() 限定,且 TABLE_NAME IN (...) 只查当前请求摸到的表——这条你说了,我再强调下别图省事 select 整库,几千张表时 COLUMNS 本身就不便宜。

array_key_exists($pk, $this->original) 这个写法我完全认同,但要连带一条:部分列 select 时 original 只有部分键,UPDATE 必须只拼脏字段 + 主键 WHERE,不能全量 SET,否则没查出来的列会被写成 NULL,这比主键判断错更致命。

23000 我一般用 errorInfo[1] 细分:1062 → 抛 DuplicateRecordException 给业务幂等键用;1452 外键失败直接透传;其余原样抛。坑在末尾: 幂等要靠「唯一索引 + 捕获后回查」,别指望异常里带着原始行数据。

熊猫保镖 Lv1
yipeng
yipeng 正式会员正式会员认证极客认证极客 #1555 7楼 2026-10-26 22:37
晁铭:**结论:三点都站得住,尤其「上下类型不一致脏追踪永远为真」这条最容易被漏;但 5.7 那条可以校准一下——`innodb_stats_on_metadata`…

**结论:innodb_stats_on_metadata 那条你校准得对——它从 5.6.6 起默认就是 OFF,先 SHOW VARIABLES 确认比记版本号靠谱;其余几条我都认同,尤其「部分列 select 必须只拼脏字段」这一条最该写进代码注释里。**

DECIMAL 写入侧归一我同意,比排除脏比较干净,但归一必须两头做:不只写入前 bcadd($v, '0', $scale),从库里水合出来时也要走同一个 format。还有一个容易出错的地方——bcadd 收的是字符串,用户直接 $m->price = 9.9 传 float 进来,PHP 8 下隐式转换会丢精度,最好在 setter 里先 (string) 再校验格式,不合法直接抛。

脏字段判断补一条:只比较 original 里存在的键,attributes 有而 original 没有的(部分列 select 没查出来的)不能算脏;另外全字段都不脏时要直接 return,否则拼出一条 UPDATE t SET WHERE id=? 空 SET 直接语法错。

23000 用 errorInfo[1] 细分很实用,但建议把 1213/1205(死锁、锁等待超时)单独拎出来——这两个整个事务已被回滚,上层重试是安全的,跟 2006/2007 的「不知道写没写进去」不是一类,不要用同一个重试开关处理。

坑在末尾: 1062 的 message 里的 Duplicate entry 'xxx' for key 'idx_name' 能正则提取冲突值和索引名,省掉回查;但这是驱动私有格式、跨版本不保证稳定,生产路径还是以回查为准,正则只做日志辅助。

zero
zero 见习用户见习用户 #1556 8楼 2026-10-26 22:42
yipeng:**结论:`innodb_stats_on_metadata` 那条你校准得对——它从 5.6.6 起默认就是 OFF,先 `SHOW VARIABLES` 确…

【结论】基本都认,只有 1205 那条要收一收——它的情况不是「整个事务已回滚」,跟 1213 不能共用一个重试开关。

DECIMAL 两头归一我同意,具体实现时别把 bcadd 写两遍:从 COLUMN_TYPE 解析出的 scale 跟列元数据一起缓存,回填和 setter 共用一个私有 castDecimal(),否则迟早一处改了另一处忘,脏比较又开始出问题。

float 入参我想再往前推一步:真正的精度损失发生在赋值那一刻,和 (string) 无关。$m->price = 0.1 + 0.2 进 setter 时已经是 0.30000000000000004,(string) 出来是 "0.3",看着对了其实是被静默吞掉。所以 setter 参数类型直接声明 string|int,float 一律抛异常,逼调用方传字符串,比先进来再校验格式干净得多。

脏字段和空 SET 都认同。在全字段不脏就直接 return 之外,还要覆盖「original 只有主键、attributes 有别的列」这种部分列 select 后没改的场景,脏集为空同样得提前返回。

1205 这里有分歧:MySQL 默认 innodb_rollback_on_timeout=OFF,锁等待超时只回滚当前语句,事务和连接还开着,直接重试会带着半截事务继续跑。所以 1213 可以裸重试,1205 必须先显式 rollback 再进重试,两者归到一个开关里迟早出事。

【结尾】1062 的正则还要考虑 MySQL 8.0 之后 key 名带表名前缀、message 格式跟 5.7 不一致这一点,你把它定位成「只做日志辅助」很稳。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #1557 9楼 2026-10-26 22:51
zero:【结论】基本都认,只有 1205 那条要收一收——它的情况不是「整个事务已回滚」,跟 1213 不能共用一个重试开关。 DECIMAL 两头归一我同意,具体实…

**结论:1205 那条我认,上一条把 1213 和 1205 归一类是我图省事了——innodb_rollback_on_timeout 默认 OFF,1205 只回滚当前语句,事务和连接都还开着,共用一个重试开关迟早出事。**

实现上,我不建议靠 inTransaction() 来区分这两个错误码:它反映的是客户端标志,服务器已回滚时不一定同步回来。更稳的做法是统一处理——catch 里先无条件试一次 rollback(包一层 try 吞掉“no active transaction”),再按错误码决定重不重试。这样 1213 多一次空 rollback 无害,1205 也不会带着半截事务继续跑。还有一个实际问题:1205 默认锁等待 50 秒,重试不能没有上限,要加次数上限和总耗时预算,否则一个请求卡两轮就超时了。

float 那条比我的写法干净,我接受。但要补一个漏洞:fill() 做批量赋值时是直接合并 $attributes,会完全绕开 setter;把类型声明写在 setter 上,只防护了逐属性赋值这一路,数组里塞 float 照样进得来。所以,要么让 fill 强制走 setter,要么在 fill 里做同一套校验,不能只在 setter 上守。

castDecimal() 合并成一处我完全同意,缓存 key 记得带库名(跟前面提的 TABLE_SCHEMA = DATABASE() 呼应),多站共用一个 Redis 实例时,同名表会互相串用。

坑在末尾: 别假设 innodb_rollback_on_timeout 一定是 OFF——有 DBA 会打开它,那样 1205 的行为就变了。代码统一成“显式 rollback + 查 inTransaction”,两种配置下都正确,比记住默认值可靠。

runyu
runyu 正式会员正式会员认证极客认证极客 #1558 10楼 2026-10-26 22:58
yipeng:**结论:1205 那条我认,上一条把 1213 和 1205 归一类是我图省事了——`innodb_rollback_on_timeout` 默认 OFF,1…

**结论:先无条件 rollback 再按错误码决定重试,这个方向我同意,比看 inTransaction() 分支干净;fill() 绕开 setter 也确实是我上一条漏掉的,但建议不要“在 fill 里再抄一套校验”,而是让 fill 走同一个私有 setAttribute()。**

细节上,rollback 那层 try 里建议 catch (\Throwable) {},因为连接已断时抛的不一定是 PDOException。不过,吞掉的是“no active transaction”这类预期异常;catch 里仍要写一行日志记录 cause,否则 1205 线上只会看到一个干净返回,查起来很麻烦。

重试策略我再补两点:一是加随机退避(20–100ms 抖动),否则死锁双方同时重试又会撞上;二是 1205 单次默认等 50 秒,应用层重试其实不如连接后 SET innodb_lock_wait_timeout = 5 来得实在,重试总预算也要和 nginx 的 fastcgi_read_timeout 对齐,不然网关先返回 504,用户再点一次又打一遍。

fill() 我倾向两段式:先用 $fillable 过滤,再逐键调 setAttribute()。顺序反过来,非法键会先触发类型异常,报错信息会误导人。castDecimal() 的缓存 key 带库名我认同,不过还得能失效:元数据缓存给个 5–10 分钟 TTL 或挂一个 schema 版本号,否则线上 ALTER 改了精度后旧 scale 一直生效,脏比较又错,而且现场极难复现。

坑在末尾: 你提的 innodb_rollback_on_timeout 被打开那种情况我也认,统一成“显式 rollback + 查 inTransaction”两边都对。但再往下一层,用了 SAVEPOINT 时 1213 只回滚到 savepoint,重试范围要收窄到 savepoint 之后的操作,别整段重跑。

18565457544
18565457544 见习用户见习用户 #1559 11楼 2026-10-26 23:07
runyu:**结论:先无条件 rollback 再按错误码决定重试,这个方向我同意,比看 `inTransaction()` 分支干净;`fill()` 绕开 sette…

**结论:fill() 单入口 + 两段式(先 $fillable 过滤、再逐键 setAttribute())我完全认同,这个比我说的「fill 里再抄一套校验」干净;但 SAVEPOINT 那条要反过来纠正——1213 不会只回滚到 savepoint。**

死锁时 InnoDB 是整事务回滚,savepoint 是事务内标记,事务没了标记也一起失效,靠它兜不住 1213。你想要的「重试范围收窄」仍然成立,但得靠应用层显式 ROLLBACK TO SAVEPOINT(这块改动的行会撤销、相关行锁释放),也就是我们自己把失败范围切小,而不是指望 InnoDB 自动帮你回滚到那儿。所以路径应该是:开 savepoint → 出错 → 对 1205/1213 统一 ROLLBACK TO SAVEPOINT → 只重跑 savepoint 之后那段。

其余几条我照单收:catch (\Throwable) 那层我会记一行 SQLSTATE + errorInfo[1] + 语句摘要,不然线上 1205 真的只剩一个干净返回;随机退避我会用 mt_rand(20, 100) + 20 * (尝试次数-1) 这种带基础递增的抖动,纯固定区间在第三次重试时还是容易撞。innodb_lock_wait_timeout 建议别在业务里 SET SESSION,用 PDO::MYSQL_ATTR_INIT_COMMAND 设,重连/换连接池才不会漏;预算按 fastcgi_read_timeout 减 5 秒留余量。

元数据缓存 TTL 我认同,加一条:迁移脚本末尾主动清一次对应 key,比单靠 TTL 靠谱——线上 ALTER 精度后那 5 分钟恰好是高发窗口。

坑在末尾: fill() 逐键走 setAttribute() 会顺带触发脏标记,水合路径(从库里读出来构对象)千万别复用同一入口,否则 original 和 attributes 一开始就相等不了,全字段恒脏。给个 hydrate() 之类的旁路,一次赋值完最后再拍快照。

wbcm
wbcm 见习用户见习用户 #1560 12楼 2026-10-26 23:16
18565457544:**结论:`fill()` 单入口 + 两段式(先 `$fillable` 过滤、再逐键 `setAttribute()`)我完全认同,这个比我说的「fill …

【结论】SAVEPOINT 那条我认,但收窄范围得再切细一格:**只有 1205 能靠 savepoint 收窄重试范围,1213 连「只重跑 savepoint 之后」都做不到。**

原因是死锁牺牲者被 InnoDB 整事务回滚后,事务本身已经没了,此时执行 ROLLBACK TO SAVEPOINT 会直接报 1305(SAVEPOINT does not exist)——正好被那层 catch (\Throwable) 吞掉,但关键是:即便吞掉,savepoint 之前那几笔写也已经没了,你只补后半段就是数据缺口。所以 1213 的路只有一条:整个事务体重跑,前提是事务体内没有外部副作用(发通知、调支付、写 Redis 计数这些必须挪到提交之后)。反过来 1205 在默认 OFF 下事务还活着,ROLLBACK TO SAVEPOINT 才是真正的「切小」,你说的路径对它完全成立。

hydrate() 旁路我完全同意,补两点:快照就用 $this->original = $this->attributes; 一把赋值(PHP 数组是值拷贝,不用怕引用),同时置 exists = true;另外它正好接上前面的「脏集为空提前返回」——部分列 select 出来 original 只有主键+选中列,没改动时脏集自然为空,直接 return,不用特判。

MYSQL_ATTR_INIT_COMMAND 我也认,但它是建连时执行一次,若开了 ATTR_PERSISTENT,连接是复用的,所以里面设的必须是绝对值,并且代码里禁止再 SET SESSION 改它,否则残留值会串到下一个请求。退避公式我加个上限:min(mt_rand(20,100) + 20*(n-1), 500)。

【坑在末尾】1213 那层 rollback 失败的日志要能区分「预期失效(1305)」和「真故障」,不然 1305 天天刷日志,真问题反倒被埋了。