dbのマスタースレーブに対応するためにacts_as_readonlyableを使ってみる
日本最大規模のrailsを使ったサイトのクックパッドで改良して
使ってるとのことなので、そこそこ使えるのだろうとのことで
改良するの覚悟でチャレンジしてみました。
下記がオフィシャルページ?(ただのblogの1ページだけども)
http://revolutiononrails.blogspot.com/2007/04/plugin-release-actsasreadonlyable.html
使い方
インストール
script/plugin install svn://rubyforge.org/var/svn/acts-as-with-ro/trunk/vendor/plugins/acts_as_readonlyable
database.yml
development: adapter: mysql database: hoge_development username: root password: hoge host: 192.168.xxx.xxx #マスターDBのIP encoding: utf8 #下記を追加 read_only: adapter: mysql database: hoge_development username: root password: hoge host: 192.168.xxx.xxx #スレーブDBのIP encoding: utf8
model(適用したいdbごとに)
class Fruit < ActiveRecord::Base acts_as_readonlyable :read_only #引数はdatabase.ymlで作った名前 end
これで下記のような動作になる。
r = Fruit.find(:first) # スレーブが読まれる。基本はスレーブ。rails2.1からは更新したフィールドの値しかupdateされないから問題はないと思われる。 r.field = 'value' r.save! # 書き込みのときはマスターに書かれる。 r = Fruit.find(:first, :readonly => false) # マスターから読まれる。明示的にマスターから読みたい場合はオプション渡しで可能 r.reload # ドキュメントにはスレーブが読まれるように書いてあるが、実際はマスターから読まれる。ソース見ると途中で変更したくさい。 r.reload(:readonly => false) # スレーブから読まれる。 r.reload(:readonly => true) # マスターから読まれる。
tips
・スレーブを複数台にする場合はdbのロードバランサーを使用する。
もし、プログラム側で行いたい場合は
modelに
acts_as_readonlyable [:first_read_only, :second_read_only]
と複数のスレーブを指定できる。(database.ymlにも項目を追加する)
・全モデルにacts_as_readonlyableを適用したい場合は
config/environment.rbに下記を追加する
class << ActiveRecord::Base def read_only_inherited(child) child.acts_as_readonlyable :read_only ar_inherited(child) end alias_method :ar_inherited, :inherited alias_method :inherited, :read_only_inherited end
ちなみに最初、↑のソースが何をするかわからなかったので
別のやりかたをrubyの黒魔術を使って書きました。。
以下に一応黒魔術テクニックとしてのせておきます。
#一度modelのクラスを全部requireし、認識できる状態にする。 Dir["#{RAILS_ROOT}/app/models/*.rb"].sort.each do |path| filename = File.basename(path) require "#{filename}" end #ActiveRecord::Baseを継承してるクラスを抽出し回して #class_evalを使ってacts_as_readonlyableを実行させる。 Object.subclasses_of(ActiveRecord::Base).each do |klass| klass.class_eval do acts_as_readonlyable :read_only end end
・トランザクションの中ではマスターを見に行く。
・なんのためかはわからないが
内部的にjoinsを渡していて且つselectを渡していない場合はreadonlyをtrueにしている。
つってもreadonlyを渡していない場合はスレーブ見にいくから意味ないと思う。。
けどなんかある気もするがわからない。。
注意点
・データを更新した後にすぐに更新後の値を見せる場合には
スレーブへのデータ反映が遅くて古い値を表示してしまう可能性がある。
改良した点
・注意点で書いた「データを更新した後にすぐに更新後の値を見せる場合」への対応として
DBのデータ更新後の一定時間はマスターを見るようにした。
controllers/application.rb
acts_as_readonlyable_update_after_fix :expires=>10.second
・findするときにオプション渡しでスレーブをoffにする機能があるが
デフォルトがスレーブはちょいと危険と思われるのでデフォルトでslaveをoffにし、通常はマスターを見るようにしてオプション渡しでslaveをonにさせた。
負荷のかかる部分に明示的に適用させるスタイル。
・コントローラーごとにslave on/offをできるようにした。オプションでonly、exceptの指定も可能。
controllers/xxx.rb
acts_as_readonlyable :only=>:index
・全モデルにacts_as_readonlyableを適用したい場合のメソッドを用意
改良版acts_as_readonlyableのソース。(acts_as_readonlyable.rbを直接更新してます。)
# Copyright (c) 2007 Revolution Health Group LLC. All rights reserved. module ActiveRecord; module Acts; end; end module ActiveRecord::Acts::ActsAsReadonlyable def self.included(base) base.extend(ClassMethods) end module ClassMethods def acts_as_readonlyable(*readonly_dbs) readonly_dbs = readonly_dbs.flatten.collect(&:to_s) readonly_models = readonly_classes(readonly_dbs) if readonly_models.empty? logger.warn("Read only mode is not activated for #{ self }") else define_readonly_model_method(readonly_models) self.extend(FinderClassOverrides) end self.send(:include, FinderInstanceOverrides) end private def readonly_classes(dbs) dbs.inject([]) do |classes, db| if configurations[RAILS_ENV][db] define_readonly_class(db) unless ActiveRecord.const_defined?(readonly_class_name(db)) classes << ActiveRecord.const_get(readonly_class_name(db)) else logger.warn("No db config entry defined for #{ db }") end classes end end def readonly_class_name(db) "Generated#{ db.camelize }" end def define_readonly_class(db) ActiveRecord.module_eval %Q! class #{ readonly_class_name(db) } < Base self.abstract_class = true establish_connection configurations[RAILS_ENV]['#{ db }'] end ! end def define_readonly_model_method(readonly_models) (class << self; self; end).class_eval do define_method(:readonly_model) { readonly_models[rand(readonly_models.size)] } end end module FinderClassOverrides def find_every(options) run_on_db(options) { super } end def find_by_sql(sql, options = nil) # Called through construct_finder_sql if sql.is_a?(Hash) options = sql sql = sql[:sql] end run_on_db(options) { super(sql) } end def count_by_sql(sql, options = nil) run_on_db(options) { super(sql) } end #KmAdd comment out #何故必要なのだろう?validates_uniquness_ofでバグる原因になってるのでコメントアウトする。 # def construct_finder_sql(options) # options.merge(:sql => super) # end def set_readonly_option!(options) #:nodoc: # Inherit :readonly from finder scope if set. Otherwise, # if :joins is not blank then :readonly defaults to true. unless options.has_key?(:readonly) if scoped?(:find, :readonly) options[:readonly] = true if scope(:find, :readonly) #KmAdd comment out # elsif !options[:joins].blank? && !options[:select] # options[:readonly] = true end end p options end def calculate(operation, column_name, options = {}) run_on_db(options) do options.delete(:readonly) super end end private def run_on_db(options) if ((Thread.current['open_transactions'] || 0) == 0) and with_readonly?(options) run_on_readonly_db { yield } else yield end end def with_readonly?(options) #KmAdd start ext_readonly = Thread.current['acts_as_readonlyable_readonly'] if ext_readonly != nil return ext_readonly end #KmAdd end #KmAdd comment out # (! options.is_a?(Hash)) || (! options.key?(:readonly)) || options[:readonly] #KmAdd start if options.is_a?(Hash) && options.key?(:readonly) return options[:readonly] elsif Thread.current['acts_as_readonlyable_controller_readonly'] return Thread.current['acts_as_readonlyable_controller_readonly'] else return false end #KmAdd end end def run_on_readonly_db klass_conn = connection begin self.connection = readonly_model.connection self.clear_active_connection_name yield ensure self.connection = klass_conn self.clear_active_connection_name end end end module FinderInstanceOverrides # backport from 1.2.3 for 1.1.6 + disable readonly by default for reload - replication lag def reload(options = {}) options[:readonly] = false unless options.has_key?(:readonly) clear_aggregation_cache clear_association_cache @attributes.update(self.class.find(self.id, options).instance_variable_get('@attributes')) self end end end end ActiveRecord::Base.send(:include, ActiveRecord::Acts::ActsAsReadonlyable) #KmAdd start module KmActsAsReadonlyable def self.apply_all_model #acts_as_readonlyableを全モデルに適用するオフィシャル方法 class << ActiveRecord::Base def read_only_inherited(child) child.acts_as_readonlyable :read_only ar_inherited(child) end alias_method :ar_inherited, :inherited alias_method :inherited, :read_only_inherited end end module Controller def self.included(base) base.extend(ClassMethods) end module ClassMethods # コントローラーレベルでのスレーブ使用をONにする。 def acts_as_readonlyable(options={}) before_filter :only => options[:only], :except => options[:except] do |c| c.__send__ :acts_as_readonlyable_action, options end end # dbのデータを更新後、一定秒以内ならマスターを見に行くようにする。 def acts_as_readonlyable_update_after_fix(options={}) before_filter :only => options[:only], :except => options[:except] do |c| c.__send__ :bf_acts_as_readonlyable_update_after_fix, options end after_filter :only => options[:only], :except => options[:except] do |c| c.__send__ :af_acts_as_readonlyable_update_after_fix, options end end end private def acts_as_readonlyable_action(options) Thread.current['acts_as_readonlyable_controller_readonly'] = true end def bf_acts_as_readonlyable_update_after_fix(options) # dbのデータを更新後、一定秒以内ならマスターを見に行くようにする。 #session[:last_db_update_at] = Time.now last_db_update_at = session[:last_db_update_at] expires = options[:expires] || 10.second p "-"*100 p expires if last_db_update_at && (last_db_update_at + expires) > Time.now Thread.current['acts_as_readonlyable_readonly'] = false end end def af_acts_as_readonlyable_update_after_fix(options) if last_db_update_at = Thread.current['acts_as_readonlyable_last_db_update_at'] session[:last_db_update_at] = last_db_update_at end end end end ActionController::Base.class_eval do include KmActsAsReadonlyable::Controller end # acts_as_readonlyable_update_after_fixのためのdb更新を検知する部分 Object.subclasses_of(ActiveRecord::ConnectionAdapters::AbstractAdapter).each do |klass| klass.class_eval do def execute_with_acts_as_readonlyable(sql, name = nil) val = execute_without_acts_as_readonlyable(sql, name) sql = sql.strip.upcase if sql.starts_with?("SET") || sql.starts_with?("SHOW") || sql.starts_with?("SELECT") || sql.starts_with?("EXPLAIN") || sql.starts_with?("BEGIN") || sql.starts_with?("COMMIT") || sql.starts_with?("ROLLBACK") return val end Thread.current['acts_as_readonlyable_readonly'] = false Thread.current['acts_as_readonlyable_last_db_update_at'] = Time.now # p "set acts_as_readonlyable_last_db_update_at. #{sql}" return val end alias_method_chain :execute, :acts_as_readonlyable end end #KmAdd end
改良verを使った適用の仕方
・全モデルにacts_as_readonlyableを適用
・applicationコントローラーにacts_as_readonlyable_update_after_fixを適用。
・トップページ等、負荷のかかるスレーブを使いたいページにコントローラーレベルのacts_as_readonlyableを適用する。
実行環境
rails(2.1.2)