在不破坏事务的情况下在Rails中的多个数据库之间切换

时间:2017-04-29 04:39:16

标签: mysql ruby-on-rails database activerecord transactions

我正在设置一个包含多个数据库的Rails应用程序。它使用ActiveRecord::Base.establish_connection db_config在数据库之间切换(所有数据库都在database.yml中配置)。

establish_connection显然会在每次通话时中断待处理的交易。一个负面结果是测试,其中必须禁用use_transactional_tests(导致不合需要的慢速测试)。

那么...... Rails应用程序如何同时在不同的数据库上维护多个事务? (为了澄清,我并不是在寻找一个奇特的跨数据库事务。只是数据库客户端的一种方式,即Rails应用程序,可以同时维护多个事务,每个数据库一个。)

我见过的唯一解决方案是putting establish_connection directly in the class definition,但假设您有一个专用于特定类的数据库。我正在应用基于用户的分片策略,其中单个记录类型分布在多个数据库中,因此需要在代码中动态切换数据库。

1 个答案:

答案 0 :(得分:6)

这是一个棘手的问题,因为ActiveRecord内部紧密耦合,但我设法创建了一些有效的概念证明。或者至少它看起来有效。

一些背景

ActiveRecord使用ActiveRecord::ConnectionAdapters::ConnectionHandler类,负责存储每个模型的连接池。默认情况下,所有模型只有一个连接池,因为通常的Rails应用程序连接到一个数据库。

对特定模型中的不同数据库执行establish_connection后,将为该模型创建新的连接池。并且还适用于可能从中继承的所有模型。

在执行任何查询之前,ActiveRecord首先检索相关模型的连接池,然后从池中检索连接。

请注意,上述说明可能不是100%准确,但应该接近。

<强>解决方案

因此,我们的想法是将默认连接处理程序替换为将根据提供的分片描述返回连接池的自定义连接处理程序。

这可以通过许多不同的方式实现。我是通过创建将分片名称作为伪装ActiveRecord类传递的代理对象来完成的。期望连接处理程序获得AR模型并查看name属性以及superclass以查看模型的层次结构链。我已经实现了DatabaseModel类,它基本上是分片名称,但它的行为类似于AR模型。

<强>实施

以下是示例实现。为简单起见,我使用了sqlite数据库,你可以在没有任何设置的情况下运行这个文件。您还可以查看this gist

# Define some required dependencies
require "bundler/inline"
gemfile(false) do
  source "https://rubygems.org"
  gem "activerecord", "~> 4.2.8"
  gem "sqlite3"
end

require "active_record"

class User < ActiveRecord::Base
end

DatabaseModel = Struct.new(:name) do
  def superclass
    ActiveRecord::Base
  end
end

# Setup database connections and create databases if not present
connection_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
resolver = ActiveRecord::ConnectionAdapters::ConnectionSpecification::Resolver.new({
  "users_shard_1" => { adapter: "sqlite3", database: "users_shard_1.sqlite3" },
  "users_shard_2" => { adapter: "sqlite3", database: "users_shard_2.sqlite3" }
})

databases = %w{users_shard_1 users_shard_2}
databases.each do |database|
  filename = "#{database}.sqlite3"

  ActiveRecord::Base.establish_connection({
    adapter: "sqlite3",
    database: filename
  })

  spec = resolver.spec(database.to_sym)
  connection_handler.establish_connection(DatabaseModel.new(database), spec)

  next if File.exists?(filename)

  ActiveRecord::Schema.define(version: 1) do
    create_table :users do |t|
      t.string :name
      t.string :email
    end
  end
end

# Create custom connection handler
class ShardHandler
  def initialize(original_handler)
    @original_handler = original_handler
  end

  def use_database(name)
    @model= DatabaseModel.new(name)
  end

  def retrieve_connection_pool(klass)
    @original_handler.retrieve_connection_pool(@model)
  end

  def retrieve_connection(klass)
    pool = retrieve_connection_pool(klass)
    raise ConnectionNotEstablished, "No connection pool for #{klass}" unless pool
    conn = pool.connection
    raise ConnectionNotEstablished, "No connection for #{klass} in connection pool" unless conn
    puts "Using database \"#{conn.instance_variable_get("@config")[:database]}\" (##{conn.object_id})"
    conn
  end
end

User.connection_handler = ShardHandler.new(connection_handler)

User.connection_handler.use_database("users_shard_1")
User.create(name: "John Doe", email: "john.doe@example.org")
puts User.count

User.connection_handler.use_database("users_shard_2")
User.create(name: "Jane Doe", email: "jane.doe@example.org")
puts User.count

User.connection_handler.use_database("users_shard_1")
puts User.count

我认为这应该让我们知道如何实施生产就绪解决方案。我希望我不会错过任何明显的东西。我可以提出几种不同的方法:

  1. 子类ActiveRecord::ConnectionAdapters::ConnectionHandler并覆盖负责检索连接池的方法
  2. 创建一个全新的类,实现与ConnectionHandler
  3. 相同的api
  4. 我想也可以只覆盖retrieve_connection方法。我不记得它的定义,但我认为它在ActiveRecord::Core
  5. 我认为方法1和方法2是可行的,应该涵盖使用数据库时的所有情况。