首页
/ Rails Active Record 7.1 核心变更深度解读:迁移策略、数据库增强与安全实践(基于 rail_inspector 记录的 CHANGELOG)

Rails Active Record 7.1 核心变更深度解读:迁移策略、数据库增强与安全实践(基于 rail_inspector 记录的 CHANGELOG)

2026-09-07 11:54:57作者:农烁颖Land

本文以仓库中保存的 Active Record 变更日志片段 tools/rail_inspector/test/fixtures/active_record_936a862.md 为骨架,逐条梳理 Rails 7.1 系列中 Active Record 的重要演进,并结合仓库源码给出验证与实现级注解。读完你将掌握:迁移执行策略模式、PostgreSQL 排他约束/索引有效性等 DDL 新能力、事务提交回调语义的变更、authenticate_by 防时序枚举认证、以及多项查询与配置优化——可直接对照 activerecord/CHANGELOG.mdlib/active_record 下的实现代码继续深入。

一、文档定位:既是变更日志,也是 rail_inspector 的测试样本

在展开技术细节前,先说明这份文档的双重身份:其内容本质上是 Rails 7.1 开发周期中 Active Record 的 CHANGELOG 条目合集(文档末尾注明"请查看 7-0-stable 获取更早变更",正文亦多次提及"7.1 起的新框架默认值"),但它被存放在 rail_inspector 的测试夹具目录下,用于验证 changelog.rb 这一静态检查器的三类规则:

  • 作者校验:每条变更必须以 *作者名* 收尾,否则报 CHANGELOG entry is missing authors
  • 缩进校验:条目头必须是 * 加 3 个空格,正文每行须缩进 4 个空格;
  • 行尾空白校验:行尾不允许残留空格或 Tab。

changelog_test.rb 中,active_record_936a862.md 恰好被当作"存在行尾空白"的反例夹具:解析该文件应检测出 16 处违规(offense)。这意味着阅读本文档时你看到的部分行尾"多余空白",正是 rail_inspector 测试刻意保留的检查目标,并非排版笔误——这也是 Rails 官方仓库通过 tools/rail_inspector 自动化约束每个框架 CHANGELOG 格式质量的真实工作方式。

而文档条目的内容本身,则覆盖了从迁移架构到安全加固的约 30 项真实变更。下文按主题归类、逐条展开,并给出可直接复制的用法。

二、迁移执行的架构演进:引入策略模式(Strategy Pattern)

文档开篇的条目指向一次架构级重构:"为执行迁移引入策略模式"

默认情况下,迁移会使用一个将方法调用委托给连接适配器的策略对象;使用者可以实现自定义策略对象,从而改变自己应用的迁移执行方式。

从源码结构看,这对应迁移执行路径的抽象拆分:默认策略把"如何执行迁移"委托给当前连接适配器(例如 PostgreSQL、MySQL、SQLite3 各自实现细节),而第三方或特定应用可以通过自定义策略介入"迁移如何在事务中运行""DDL 如何下发"等环节。它带来的直接收益是:过去散落在 migrate 流程里的命令执行逻辑被收敛为一个可替换的对象,便于框架演进与个性化扩展。

实践含义:如果你的团队有"迁移前自动备份""迁移白名单审批""迁移超时保护"等特殊诉求,7.1 之后可以通过提供自定义 strategy 对象切入,而不再需要 monkey-patch 适配器。

三、DDL 与 Schema 能力增强

本节涵盖数据库结构变更(迁移)层面的新特性与修复,逐条对照原文展开。

3.1 通过 database.yml 全局禁用外键

新增适配器选项,即使底层数据库支持外键,也可以整体跳过外键约束的创建/使用:

development:
    <<: *default
    database: db/development.sqlite3
    foreign_keys: false

适用场景如:数据仓库、导入海量历史数据的环节,或希望由应用层而非数据库层管理引用完整性时。注意该选项是 database.yml 层面的配置,影响整个连接;若只是单个迁移想要跳过外键,仍应使用迁移中已有的 disable_referential_integrity 等细粒度 API。

3.2 排他约束支持(仅 PostgreSQL)

新增 add_exclusion_constraint / remove_exclusion_constraint,用于表达"区间不重叠"这类超越普通唯一索引的约束:

add_exclusion_constraint :invoices, "daterange(start_date, end_date) WITH &&", using: :gist, name: "invoices_date_overlap"
remove_exclusion_constraint :invoices, name: "invoices_date_overlap"

典型业务是预订系统防止同一资源时间片重叠(会议室、房间、车辆排期)。实现上,这些方法由 PostgreSQL 适配器的 schema_statements 承担,并通过 schema_definitions.rb 暴露给迁移 DSL。

3.3 带有效性(validity)的 PostgreSQL 索引查询

此前只能盲目信任索引元数据,现在可直接查询索引的 valid 状态:

connection.index_exists?(:users, :email, valid: true)
connection.indexes(:users).select(&:valid?)

这通常配合 CREATE INDEX CONCURRENTLY 失败、需要排查"索引是否真正建成"的运维场景。

3.4 表名长度强制上限

修复了 #45130:此前超长表名会延迟到数据库报错,现在框架层即强制限制表名长度,让开发者更早发现命名问题(尤其在使用长前缀或复合命名空间时)。

3.5 change_column_null 严格化:只接受布尔值

历史行为是"非 false 即 nullable",容易埋雷——例如误传 from: true, to: false 曾静默让列变成可空。7.1 起参数必须是 truefalse

change_column_null :table, :column, true  # 正确
change_column_null :table, :column, false # 正确
change_column_null :table, :column, from: true, to: false # 抛错(此前会把列改成可空)

3.6 命名表达式索引支持回滚(reversible)

此前可逆迁移在回滚时报错,因为删除索引时未使用显式索引名:

add_index(:settings, "(data->'property')", using: :gin, name: :index_settings_data_property)

修复后(对应 #43331),回滚会依据 name 正确执行 remove_index,保证 rollback 不中断。

3.7 外键与注释相关修复

  • 修复 remove_foreign_key 携带 :if_exists 时"外键实际存在却删除失败"的问题;
  • 修复 MySQL 适配器中 change_column_comment 丢失列 AUTO_INCREMENT 属性的问题(改注释不应伤及自增属性);
  • 调整 MariaDB 支持 check 约束的最低版本判断,并修复 MariaDB 默认函数支持:此前 db/schema.rb 会写错默认值,db:schema:load 无法正确恢复,且函数名会被当作字符串内容写入新纪录。

3.8 SQLite 6.0 版本迁移兼容性

当迁移版本号为 6.0 时,references/belongs_to 在 SQLite 适配器下会创建 integer 列而非此前的 bigint,对齐 6.0 时代的行为预期,避免旧项目升级后 schema 不一致。

四、连接、配置与 Schema Dump 变化

4.1 SQLite3 默认开启严格字符串模式

SQLite 对双引号字符串存在历史怪癖:先尝试按标识符(列名)解析,失败才当作字符串字面量,导致"给不存在的列建索引"这类拼写错误被静默放过。7.1 起 SQLite3Adapter 默认关闭该宽容行为;如确需恢复旧行为,可在应用配置中显式关闭:

# config/application.rb
config.active_record.sqlite3_adapter_strict_strings_by_default = false

4.2 mysql2 适配器 prepared_statements 未配置时给出弃用警告

未显式设置 prepared_statements 的 mysql2 应用会收到弃用提示,推动开发者明确选择(默认与否影响预编译语句的内存与安全性权衡)。

4.3 移除 ActiveRecord.legacy_connection_handling

遗留的连接处理开关被彻底删除,标志多数据库时代的连接管理正式收敛到新的实现路径上。

4.4 ConnectionPool 支持 Fiber 隔离

ActiveSupport::IsolatedExecutionState.isolation_level 设为 :fiber 时,同一线程内的多个 Fiber 可以各自从连接池检出连接——这是 Rails 拥抱 Fiber 并发模型的基础设施准备。

4.5 SCHEMA_FORMAT 环境变量控制 schema dump 格式

由于 rails db:structure:{dump,load} 已弃用,若想同时产出 SQL 与 Ruby 两种格式的 schema,可用环境变量:

SCHEMA_FORMAT=sql rake db:schema:dump

rails db:schema:{dump,load} 现在会优先读取 ENV["SCHEMA_FORMAT"],其次才是配置

4.6 结构转储(structure.sql)相关修复

  • 去掉 --no-comments 标志:它破坏使用自定义 schema 注释的应用;若确实不需要注释,请显式配置:
ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = ['--no-comments']
  • 修正 PG structure dump 任务中把 --no-comment(单数,Rails 7 引入时的笔误)改为正确的 --no-comments
  • 反转 structure.sqlINSERT 语句的顺序:新迁移产生的插入排在最前,显著降低多人并行开发时的合并冲突概率——注意既有应用首次重新生成时会出现一次较大的 diff。

4.7 死锁/序列化失败后的连接处理修复

在 6.1.4 之前,序列化失败与死锁会对真实事务和 savepoint 都发起 rollback;6.1.4 又把这些 rollback 全部移除,导致 MySQL 上连接残留未知状态而被丢弃。现在恢复这些 rollback,唯一例外是 MySQL 上的 savepoint 不执行 rollback(因为 MySQL 禁止死锁后回滚 savepoint),从而避免误丢数据库连接。

4.8 VERSION 参数允许下划线

数据库 rake 任务(如 db:migrate:down VERSION=...)的版本号参数现在允许包含下划线,适配更多命名习惯。

五、事务回调语义与记录更新

5.1 在"最新鲜"的实例上执行事务回调

这是语义变化最大的条目之一。当一个事务内多个 Active Record 实例先后改动同一条记录时,after_commit/after_rollback 只会对其中一个实例触发。新配置决定框架如何挑选该实例:

config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction
  • true 时:回调运行在第一个保存记录的实例上(其实例状态可能是陈旧的);
  • false 时(7.1 起的新框架默认):回调运行在实例状态最新鲜的实例上。挑选规则如下:
    • 一般规则:运行在事务内最后一次保存该记录的实例上;
    • 例外一:若记录在事务内被创建、随后被另一实例更新,则 after_create_commit 回调运行在第二个实例上(而不是按其状态"想当然"触发 after_update_commit);
    • 例外二:若记录在事务内被销毁,则 after_destroy_commit 在最后一个被销毁的实例上触发,即使之后有陈旧实例又执行了一次更新(实际影响 0 行)。

相关实现位于 连接适配器的抽象事务层升级到 7.1 的应用务必复核:回调内若依赖实例状态,必须确认其引用的是否为"最后一次保存的实例",避免基于陈旧内存状态做决策。

5.2 新增 update_attribute!

update_attribute 类似,但当 before_* 回调 throw(:abort)抛出 ActiveRecord::RecordNotSaved,而非静默返回 false

class Topic < ActiveRecord::Base
  before_save :check_title

  def check_title
    throw(:abort) if title == "abort"
  end
end

topic = Topic.create(title: "Test Title")
topic.update_attribute!(:title, "Another Title")
topic.update_attribute!(:title, "abort")
# raises ActiveRecord::RecordNotSaved

5.3 异步销毁关联的两处完善

  • 修复 config.active_record.destroy_association_async_job 被忽略的问题:此前 has_many ... dependent: :destroy_async 总是使用默认的 ActiveRecord::DestroyAssociationAsyncJob,无法替换为应用自定义后台任务;
  • 新增 config.active_record.destroy_association_async_batch_size:允许指定单个后台任务最多销毁多少条记录。默认保持原行为(父记录销毁时,所有依赖记录在一个任务内处理);当依赖记录数超过该配置时,拆分为多个后台任务,避免超长队列拖垮单个 worker。

六、查询、校验与缓存性能优化

6.1 有唯一索引时跳过未变更字段的唯一性校验

此前,保存记录会对每个带 uniqueness 校验的字段额外发一条查询,即使该字段没有变化。若数据库已存在对应的唯一索引,则该校验对已持久化记录永远不会失败,因此可安全跳过——显著减少高频保存时的查询量(由 fatkodima 提交)。

6.2 矛盾关系的聚合计算不再发查询

对必然为空的条件(如 User.where(id: []).count)执行 countsumaverageminimummaximum 时,不再向数据库发出永远返回空结果的查询。

6.3 修复 Relationcache_version 陈旧问题

此前调用 reset 不会清空 cache_versions 实例变量,导致数据正确但版本号仍报旧值。现在 reset 会连带重置缓存版本:

developers = Developer.all
developers.cache_version

Developer.update_all(updated_at: Time.now.utc + 1.second)

developers.cache_version # Stale cache_version
developers.reset
developers.cache_version # Returns the current correct cache_version

对应 issue #45341,直接影响基于 cache_version 做 HTTP 缓存/ETag 的应用。

6.4 in_order_of 的两处行为修正

  • 现在会过滤掉未列在 values 中的记录,与 Enumerable#in_order_of 语义对齐;
  • 处理空列表不再报错:Post.in_order_of(:id, []).to_a 返回空集合;同时把该列明确设为次级排序键,保证"其余值仍有序"。

6.5 pretty_print 不再加载整张表

pp Foo.all 过去会加载全表数据,现在只展示前 10 条并输出省略号,避免调试时误触大规模查询。

6.6 计算方法的列别名安全引用

由计算生成的列别名源自表名,不能假设其为合法标识符。此前 table_name = '1abc' 的模型执行 Test.group(:id).count 会生成 AS 1abc_id 这样的非法 SQL 而抛 ActiveRecord::StatementInvalid,现已修正引用方式。

6.7 insert_all / upsert_all 支持别名属性

配合 alias_attribute 映射,批量写入可用别名作为键,返回时也可用别名:

class Book < ApplicationRecord
  alias_attribute :title, :name
end

Book.insert_all [{ title: "Remote", author_id: 1 }], returning: :title

6.8 加密属性支持数据库默认值列

定义在"带默认值的列"上的加密属性,在创建记录时会自动加密该默认值;此前必须显式设置 config.active_record.encryption.support_unencrypted_data = true 才能避免报错。

6.9 其余正确性修复

  • Hstore 反序列化回归修复:避免 hstore 类型读回时出现类型错乱;
  • 无主键模型的热加载(eager loading)修复has_many/belongs_to 组合在无主键模型上不再抛错;
  • touch 对只读(readonly)列现在会抛出错误,而不是静默失败;
  • 允许带 COLLATE 的列名(如 title COLLATE "C")作为安全 SQL 字符串;
  • 停止设置 sql_auto_is_null:自 MySQL 5.5 起该选项默认关闭,无需再手工干预。

七、安全:authenticate_by 抵御时序枚举攻击

has_secure_password 新增类方法 authenticate_by,目标是替换以下"先按邮箱查用户再认证"的常见写法:

User.find_by(email: "...")&.authenticate("...")

上述写法存在基于响应时间的账号枚举漏洞:当邮箱不存在时提前返回,攻击者可借此判断某邮箱是否注册,进而针对该邮箱做撞库(密码复用)与定向钓鱼。authenticate_by 无论用户是否存在都花费相同时间完成响应,从时间维度消除账号存在性信号:

User.authenticate_by(email: "...", password: "...")

该方法的实现位于 secure_password.rb,并在 secure_password_test.rb 中有完整的用例覆盖。凡提供登录接口的应用,都应当用 authenticate_by 取代 find_by(...)&.authenticate(...) 的旧写法——这也是 7.1 认证基线安全建议中最容易落地的一项。

八、测试基础设施与杂项改进

  • fixtures 访问器的内存占用大幅下降:过去用 define_method 急切定义全部访问器,内存随 fixture 数量线性膨胀;改为基于 method_missing 的延迟实现后,内存与 CPU 开销显著减小(Jean Boussier 提交),大型测试套件受益明显;
  • ActiveRecord::DatabaseSelector::Resolver#reading_request? 允许被覆盖:默认实现按请求是否为 get?/head? 判断,你现在可以自定义判定逻辑;返回 true 时调用 read,意味着该请求可由只读副本库服务——适合改造"POST 中也含只读场景"的路由分流策略;
  • 修复 PG.connect 关键字参数在 Ruby 2.7 下的弃用警告(对应 #44307);
  • 修复 MySQL 适配器对 ActiveSupport::DurationRational 的引号处理,避免参数化时类型误转。

九、如何基于本仓库继续深入研究

升级建议小结:本文档所列变更中,优先关注三类会影响既有应用行为的项——事务提交回调的新默认实例选择规则(第五小节)、SQLite 严格字符串模式与 mysql2 prepared_statements 弃用提示(第四小节)、以及 in_order_of 与矛盾关系聚合的新语义(第六小节);而 authenticate_by、排他约束与 update_attribute! 则属于可以直接引入的新能力。

登录后查看全文
热门项目推荐
相关项目推荐