Skip to content

Instantly share code, notes, and snippets.

@zhangyuan
Last active May 29, 2016 16:29
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save zhangyuan/6269419 to your computer and use it in GitHub Desktop.
Save zhangyuan/6269419 to your computer and use it in GitHub Desktop.
给 Rails3 项目添加简单的主从数据库分离访问

给 Rails3 项目添加简单的主从数据库分离访问

特别感谢:我实现的主从分离,基于 tumayun ( https://github.com/tumayun )的 master_slave ( https://github.com/tumayun/master_slave )项目。该项目是 rails 4 的。由于 Rails4 和 Rails3 在数据库访等方面的接口变化,导致该项目不适合 rails3,因此我将其改造成 rails3,并写了这篇笔记分析其实现。

( 我的Blog http://blog.yuaz.net 暂时无法无法创建文章,因此将这篇笔记贴在这里)

默认情况下,Rails 项目仅支持一个数据库访存。当出现数据库访问瓶颈时,就要考虑主从数据库分离:在主库上写,在从库上读。只要从库的延迟在可接受的范围内,这样的分离可以大大提高性能。本文分析Rails的源码,介绍添加从库的方式。

在程序上,如何访问从库?

大部分情况下,项目从一开始都不考虑主从分离。因此,对从库的支持,通常是在对现有代码的重构基础上进行。原则是,在不大改现有代码的前提下,支持从库;并且能很方便地调用。这里希望通过Ruby语言的代码块来切库。

用法举例

假设现有的读库程序为

posts = Post.recent.limit(10)

那么使用如下方式,在代码块使用从库,在代码块之外访问使用原有逻辑。

Post.using(:slave) do
  posts = Post.recent.limit(10)
end

这样,Post.using 方法应该实现:将数据库连接切换到从库,调用代码块,将数据库连接还原。

如何实现?

查阅 Rails 的源码可知,所有的数据库操作,都落在了 ActiveRecord::Base.connection 方法上。因此,可以修改这个方法,在 Post.using(:slave) {} 代码块里,使用从库;在代码外,使用原来的方式。

伪代如下

def connection_with_master_slave
  if using_slave?  # 是否使用从库
    # connection_with_slave  # 返回从库连接
  else
    # connection_without_slave # 返回主库的连接
  end
end

如何访问从库呢?

建立连接

通常可以使用 ActiveRecord::Base.establish_connection 来建立连接。源码如下

    # activerecord/lib/active_record/connection_adapters/abstract/connection_specification.rb +128
    
    def self.establish_connection(spec = ENV["DATABASE_URL"])
      resolver = ConnectionSpecification::Resolver.new spec, configurations
      spec = resolver.spec

      unless respond_to?(spec.adapter_method)
        raise AdapterNotFound, "database configuration specifies nonexistent #{spec.config[:adapter]} adapter"
      end

      remove_connection
      connection_handler.establish_connection name, spec
    end

ActiveRecord::Base 会持有一个类变量 connection_handler ,它是 ActiveRecord::ConnectionAdapters::ConnectionHandler 的实例 ,从字面上就能看出 ActiveRecord::Base 通过它来管理数据库连接,并通过它来真正创建连接。

ActiveRecord::ConnectionAdapters::ConnectionHandler#establish_connection 实现如下

      def establish_connection(name, spec)
        @connection_pools[spec] ||= ConnectionAdapters::ConnectionPool.new(spec)
        @class_to_pool[name] = @connection_pools[spec]
      end

从这里可以看出 spec 是连接池的标识符。通过阅读更多源码可知,ActiveRecord 将类名(即这里的name)和连接池,通过 @class_to_pool 对应起来。这样不同的类,可以采用不同的连接池了。

但增加从库,并不是实现不同的类,使用不同的连接池。而是同一个类,在不同场景下,使用不同的连接池。因此,要对每个从库建立一个连接池。并且有唯一标识。只要参考 ActiveRecord::Base.establish_connection 的实现,来创建连接即可。

另外在 ActiveRecord::Base.establish_connection 里,调用了 remove_connection 方法,其实现为

      def remove_connection(klass = self)
        connection_handler.remove_connection(klass)
      end

ActiveRecord::ConnectionAdapters::ConnectionHandler#remove_connection

      # activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +373
      def remove_connection(klass)
        pool = @class_to_pool.delete(klass.name)
        return nil unless pool

        @connection_pools.delete pool.spec
        pool.automatic_reconnect = false
        pool.disconnect!
        pool.spec.config
      end

可以看出这里移除了类和连接池的对应关系。方法里调用了 klass.name。这里看起来有些不一致, ActiveRecord::ConnectionAdapters::ConnectionHandler(name, spec) 在创建连接时,直接使用了参数 name ,而在 ActiveRecord::ConnectionAdapters::ConnectionHandler.remove_connection(klass) 时,又调用了 klass.name

因此,这里需要一个拥有 #name 方法的对象,返回连接池的标识。这里定义新的类来做

module MasterSlave
  module ConnectionHandler
    class ArProxy
      attr_reader :name

      def initialize(name)
        @name = name
      end
    end
  end
end

创建连接池的代码如下

module MasterSlave
  class ConnectionHandler
    def self.setup_connection
      # activerecord/lib/active_record/connection_adapters/abstract/connection_specification.rb +128
      configuration = {"adapter"=>"sqlite3", "database"=>"db/slave.sqlite3", "pool"=>5, "timeout"=>5000}

      resolver = ActiveRecord::Base::ConnectionSpecification::Resolver.new(configuration, nil)

      spec = resolver.spec

      unless ActiveRecord::Base.respond_to?(spec.adapter_method)
        raise AdapterNotFound, "database configuration specifies nonexistent #{spec.config[:adapter]} adapter"
      end

      ar_proxy = ArProxy.new(connection_pool_name(slave_name))

      # activerecord/lib/active_record/connection_adapters/abstract/connection_specification.rb +179
      # activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +424
      # remove_connection 时会调用方法内部 ar_proxy.name
      ActiveRecord::Base.remove_connection(ar_proxy)

      # activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +373
      # establish_connection 时,使用 ar_proxy.name
      ActiveRecord::Base.connection_handler.establish_connection ar_proxy.name, spec
    end
  end
end

以上创建连接的代码,应该在rails启动时执行,且执行一次。

切换连接

ActiveRecord::Base.connection 的实现如下:

    class << self
      # Returns the connection currently associated with the class. This can
      # also be used to "borrow" the connection to do database work unrelated
      # to any of the specific Active Records.
      def connection
        retrieve_connection
      end
      
      def retrieve_connection
        connection_handler.retrieve_connection(self)
      end
    end

module ActiveRecord
  module ConnectionAdapters
  
    def retrieve_connection(klass) #:nodoc:
      pool = retrieve_connection_pool(klass)
      (pool && pool.connection) or raise ConnectionNotEstablished
    end
  
    def retrieve_connection_pool(klass)
      pool = @class_to_pool[klass.name]
      return pool if pool
      return nil if ActiveRecord::Base == klass
      retrieve_connection_pool klass.superclass
    end
  end
end

可以看出,获取连接最终使用的是 ActiveRecord::ConnectionAdapters#retrieve_connection_pool(klass) ,注意使用了 klass.name 方法。参考建立连接的代码,应该使用如下代码获取合适的连接池。

ActiveRecord::Base.connection_handler.retrieve_connection(ar_proxy)

最终,切换数据库连接的代码如下

module MasterSlave
  module Base
    extend ActiveSupport::Concern
    
    included
      # rails 3 弃用了 alias_method_chain,所以这里用 alias_method 实现类似功能
      alias_method :connection_without_master_slave, :connection
      alias_method :connection, :connection_with_master_slave
    end
    
    module ClassMethods
      def connection_with_master_slave
        slave_block        = Thread.current["[MasterSlave]slave_block"]
        current_slave_name = Thread.current["[MasterSlave]current_slave_name"]

        if slave_block && current_slave_name
          pool_name  = MasterSlave::ConnectionHandler.connection_pool_name(current_slave_name)
          ar_proxy   = MasterSlave::ConnectionHandler::ArProxy.new(pool_name)
          ActiveRecord::Base.connection_handler.retrieve_connection(ar_proxy)
        else
          connection_without_master_slave
        end
      end
    
      def using(slave_name, &block)
        # 用thread local 变量来保存当前的数据库主从状态
        Thread.current["[MasterSlave]current_slave_name"] = slave_name
        Thread.current["[MasterSlave]slave_block"] = true
        
        yield
        
        ensure
          Thread.current["[MasterSlave]current_slave_name"] = nil 
          Thread.current["[MasterSlave]slave_block"] = false
        end
      end
    end
  end
end

将上面的创建连接和切换数据库代码的模块,混入到 ActiveRecord::Base 中,就能达到同一个类,在不同场景下,使用不同数据库的目的。

其他问题

在fork后是否需要重新连接

使用 Passenger 并开启 Spawning 模式时(参见 http://www.modrails.com/documentation/Users%20guide%20Nginx.html#spawning_methods_explained ),通常需要在工作进程fork之后,重新打开文件描述符,即重新连接数据库。那么这里添加了从库后,是否需要在fork后重新连接?

首先,通常使用 Passenger 时,我们并不会处理 ActiveRecord 的连接,是否意味着 Passenger 已经帮我们处理了?以 ActiveRecord 为关键词搜索 Passenger 的源码,可以找到代码(见 https://github.com/phusion/passenger/blob/release-3.0.21/lib/phusion_passenger/utils.rb#L383

    # If we were forked from a preloader process then clear or
    # re-establish ActiveRecord database connections. This prevents
    # child processes from concurrently accessing the same
    # database connection handles.
    if forked && defined?(::ActiveRecord::Base)
      if ::ActiveRecord::Base.respond_to?(:clear_all_connections!)
        ::ActiveRecord::Base.clear_all_connections!
      elsif ::ActiveRecord::Base.respond_to?(:clear_active_connections!)
        ::ActiveRecord::Base.clear_active_connections!
      elsif ::ActiveRecord::Base.respond_to?(:connected?) &&
            ::ActiveRecord::Base.connected?
        ::ActiveRecord::Base.establish_connection
      end
    end

ActiveRecord::Base.clear_all_connections! 的实现为:

      # activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb
      def clear_all_connections!
        @connection_pools.each_value {|pool| pool.disconnect! }
      end

可见 Passenger 已经对 ActiveRecord 的连接做了特殊处理。会在创建工作进程后,断开所有的连接池中的连接。

测试

默认情况下,每一个测试用例都在一个事务中。但是,使用从库后,主库和从库是两个连接,因此,在主库里插入数据库,切换到从库是看不到的。

有两种解决方法:

  1. 在测试环境下,关闭从库访问的功能(这意味着单元测试覆盖不到从库,但我觉得通常没必要测试从库)。
  2. 在测试环境下,关闭事务,同时在每个测试用例的前后清理数据(比如使用 database_cleaner,将 strategy 设置为 truncation,但是,这样会导致测试非常慢)。
@fullbearded
Copy link

@joshsulin
Copy link

一定会好好的体会一下

@scuzhanglei
Copy link

nice

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment