Rails中如何基于Transactions表自动更新聚合Rankings表
Rails 交易聚合排名表实现方案
逻辑放置位置说明
不要跨控制器调用业务逻辑,也不要把聚合更新逻辑写在TransactionsController或RankingsController中。控制器的核心职责仅为处理请求参数校验、权限校验、响应结果返回,耦合业务逻辑会导致代码无法在非HTTP场景(如控制台操作、后台任务、批量数据导入)复用,也会让控制器代码过度臃肿。
推荐把聚合更新逻辑下沉到模型层,或者直接放在数据库层,以下是两种兼顾可读性和运行效率的实现方案:
方案1:ActiveRecord 回调实现(可读性优先,适合中小流量场景)
该方案完全遵循Rails约定,代码可读性高,维护成本低。
前置准备:创建Rankings表
首先生成迁移文件创建聚合表,必须给Rankings表的user_id加唯一索引,既保证数据唯一性,也能让后续原子操作效率更高:
# db/migrate/2024xxxxxx_create_rankings.rb class CreateRankings < ActiveRecord::Migration[7.0] def change create_table :rankings do |t| t.bigint :user_id, null: false t.decimal :amount_total, default: 0, null: false t.timestamps end add_index :rankings, :user_id, unique: true end end
执行迁移后配置模型关联:
# app/models/user.rb class User < ApplicationRecord has_many :transactions has_one :ranking end # app/models/transaction.rb class Transaction < ApplicationRecord belongs_to :user # 事务提交后再更新排名,避免事务回滚导致排名数据脏写 after_commit :update_user_ranking, on: :create private def update_user_ranking # 原子Upsert操作,无需先查询判断记录是否存在,无并发竞态问题 Ranking.upsert( { user_id: user_id, amount_total: amount }, unique_by: :user_id, on_duplicate: Arel.sql("amount_total = rankings.amount_total + EXCLUDED.amount_total") ) end end
该方案的优势:
- 代码全在Ruby层,可读性高,新开发者可以快速定位逻辑
- 原子操作没有竞态问题,比传统
find_or_create_by+累加的写法性能高30%以上
方案2:数据库触发器实现(性能优先,适合高并发大流量场景)
如果交易创建的QPS较高,Ruby层回调的开销不可忽略,可以选择把逻辑完全下沉到数据库层,性能最高,且无论通过什么方式写入交易数据(ActiveRecord、直接SQL、批量导入)都会触发更新,不会遗漏。
创建触发器的迁移文件如下:
# db/migrate/2024xxxxxx_add_transaction_trigger_for_ranking.rb class AddTransactionTriggerForRanking < ActiveRecord::Migration[7.0] def up execute <<~SQL CREATE OR REPLACE FUNCTION update_ranking_amount() RETURNS TRIGGER AS $$ BEGIN INSERT INTO rankings (user_id, amount_total, created_at, updated_at) VALUES (NEW.user_id, NEW.amount, NOW(), NOW()) ON CONFLICT (user_id) DO UPDATE SET amount_total = rankings.amount_total + NEW.amount, updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_after_transaction_create AFTER INSERT ON transactions FOR EACH ROW EXECUTE FUNCTION update_ranking_amount(); SQL end def down execute <<~SQL DROP TRIGGER IF EXISTS trigger_after_transaction_create ON transactions; DROP FUNCTION IF EXISTS update_ranking_amount(); SQL end end
该方案的优势:
- 完全没有Ruby层开销,性能是所有方案中最高的
- 无论通过什么途径写入交易数据都会自动更新聚合表,不会出现数据不一致
- 缺点是逻辑存放在数据库层,排查问题时需要额外注意触发器逻辑
优化建议
- 如有批量导入交易的需求,不要使用单条回调的方式处理,建议批量插入交易完成后,统一用分组聚合SQL批量更新Rankings表,避免N次数据库操作
- 如果需要Transaction和Ranking的强一致,可以把更新逻辑放到
after_create回调中,和Transaction创建包裹在同一个事务内,避免交易创建成功但排名更新失败的情况 - 不要使用
user.ranking关联查询后再更新的写法,会产生额外的查询开销,还会有并发竞态导致的累加错误问题
内容的提问来源于stack exchange,提问作者paul88
相关产品推荐
相关产品推荐

