PAYATHON 2026

删除 nested object 后如何触发 count 更新

支付小周

结论

不要在 after_save 中再次调用 save。这样会重复触发 after_save,最终导致 stack level too deep。

处理 nested attributes 时,通常可以继续使用 before_save,但不要用下面这种直接查询数据库的方式计算:

payment_items.sum(:total_payout_cents)

应该根据内存中的关联对象求和,同时排除已经标记为删除的记录:

before_save :update_payment_total

private

def update_payment_total
  self.amount_cents = payment_items
    .reject(&:marked_for_destruction?)
    .sum { |item| item.total_payout_cents.to_i }
end

这样,无论 payment items 是新增、修改,还是通过 _destroy 删除,都能正确参与计算。计算结果也会和 Payment 在同一次保存事务中写入。

为什么原来的计算不准确

下面这行代码通常会生成 SQL,汇总数据库中当前已有的记录:

payment_items.sum(:total_payout_cents)

执行 before_save 时,通过 nested attributes 删除的记录往往只被标记为待删除:

item.marked_for_destruction? # => true

这些记录此时可能还在数据库中,所以 SQL 的 SUM 仍会将它们计入总额。与此同时,尚未写入数据库的新记录,以及尚未保存的金额修改,也可能无法通过数据库聚合得到。

改用 Ruby 块求和,会读取当前请求处理后的内存状态:

payment_items.sum { |item| item.total_payout_cents.to_i }

再用 marked_for_destruction? 排除即将删除的项目,就能算出本次保存完成后应有的总额。

完整示例

假设模型使用 accepts_nested_attributes_for:

class Payment < ApplicationRecord
  has_many :payment_items, dependent: :destroy

  accepts_nested_attributes_for :payment_items,
                                allow_destroy: true

  before_save :update_payment_total

  private

  def update_payment_total
    self.amount_cents = payment_items
      .reject(&:marked_for_destruction?)
      .sum { |item| item.total_payout_cents.to_i }
  end
end

提交 nested attributes 时,可以通过 _destroy 标记要删除的记录:

{
  payment: {
    payment_items_attributes: {
      "0" => {
        id: "123",
        _destroy: "1"
      }
    }
  }
}

启用 allow_destroy: true 后,对应对象会在保存过程中被标记为删除。上面的 callback 在计算总额时会忽略该对象。

如果必须基于数据库中已经保存的数据计算

如果业务要求等所有关联记录提交后,再通过数据库聚合计算总额,可以使用 after_commit,并通过跳过 callback 的方式更新字段:

class Payment < ApplicationRecord
  has_many :payment_items, dependent: :destroy

  after_commit :refresh_payment_total, on: %i[create update]

  private

  def refresh_payment_total
    update_column(
      :amount_cents,
      payment_items.sum(:total_payout_cents)
    )
  end
end

update_column 不会再次运行模型的保存 callback,因此不会造成递归。

但这种写法会多执行一次数据库更新,而且总额更新和 Payment 的首次保存不在同一个事务中。对于大多数 nested attributes 场景,前面的 before_save 配合 marked_for_destruction? 更合适。

注意事项

  • 不要在 after_save 中直接调用 save、save!、update 或 update_attributes,否则会再次触发 after_save。
  • 金额字段可能为 nil 时,可以用 to_i 将其按 0 处理。如果 nil 属于非法数据,应该添加 validation,而不是静默转换。
  • 如果 PaymentItem 可以绕过 Payment 单独创建、修改或删除,Payment 上的 callback 不一定会执行。此时可以通过服务对象统一处理修改,或者在 PaymentItem 的 after_commit 中重新计算所属 Payment。
  • 如果项目数量很大,将全部记录加载到内存中可能带来性能问题。这时可以在事务完成后使用数据库聚合,但要接受第二次更新以及并发一致性方面的取舍。

备注:内容仅供参考。