石锅拌饭

Tag: cache

再谈rails缓存机制的问题

by Robin Lu on Jan.31, 2008, under Uncategorized

同样,我们还是从一个典型的缓存操作开始。很多时候,当我们创建或者修改了一个active record对象后,需要清除一些对应的缓存,无论是借助Sweeper,还是自己创建Observer,甚至直接写在filter里,最终基本上都归结到一个类似下面的callback,通过上述几种方式在save后被调用:

# If we save an article, the cached version of that article is stale
def after_save(article)
  expire_article_page(article.id)
end

一旦保存修改了该对象,就清除缓存。无论你看ActionController::Caching::Sweeping的文档,还是Agile Web Development with RailsCaching, Part OneExpiring Pages Implicitly一节,都会这么告诉你。

看似没什么问题,但在高并发的环境中(又是高并发),经常出现缓存被清除之后,重建缓存仍然使用了过期的数据。

我们首先怀疑是出现了类似“memcached使用中的竞争条件”中提到的问题,于是在缓存清除和创建的地方分别添加日志。结果很让我们吃惊,在after_save里用find取出的数据的确是新的,创建缓存的时间明显晚于after_save被调用的时间,但用同样的方法取出的数据却是旧的。

经过调查,我们终于抓出了今天的陷阱主角,ActiveRecord 的 Built-in Transactions 。

为了保证数据更新操作的原子性,ActiveRecord缺省将destroy和save操作都包裹在一个transaction中(详见activerecord/lib/transaction.rb),而整个 filter chain 也被包含了进来,也就是说,before_save和after_save都是在transaction提交前发生的。

什么是transaction?就是在transaction commit成功返回之前,所有的数据库操作并没有真正在服务器端完成。但在after_save中调用expire_article_page(article.id),这个函数一返回,缓存就已经被清除了,不会等到transaction commit。

一个non-transactional的操作被放在了一个transaction中。

结果是什么?

结果是在缓存被清除和最后transaction commit完成之间,缓存重建有可能被另一个rails实例发起,使用的是没有被更新的数据。

这是一个相当隐蔽的陷阱,一个马上可以想到的解决方法是在transactional save/destroy之外再包一层,在这一层加上自定义的filter,在save之外调用自定义的filter,保证清除缓存是在transactional save之后。

但这样又陷入一个新的陷阱。ActiveRecord的built-in transaction是可以嵌套的。也就是说,在一个after_save中可以调用另一个ActiveRecord对象的save,而这个内层save结束并不会触发commit,只会decrement_open_transactions,减少一层嵌套而已。

通过在transactional save/destroy外再包一层的做法虽然能保证自定义的filter在save后执行,却不能保证在transaction commit之后。

由此再引申出一个解决方案,重载Caching的API,在Sweeper中使用新定义的expire,所有的expire并不真正清除缓存,而是将要清除的缓存记录下来,延后执行。再重载commit_db_transaction,在真正的commit之后执记录下来的操作。这种延迟执行的方法应该适用于所有需要在filter chain中调用的non transactional方法。

这已经是第三篇谈到rails缓存相关的问题了,也许你对另外两篇也感兴趣:
“rails缓存机制的几个问题”
“memcached使用中的竞争条件”

快成系列了。

7 Comments :, , more...

rails缓存机制的几个问题

by Robin Lu on Apr.28, 2007, under Uncategorized

ruby on rails提供了一些内建的cache机制,我们比较多的用到了其中的fragment cache。在实际使用过程中,发现了一些问题,如果你不注意,performance和正确性可能都会受到影响。

cache的重复读取问题

这是从Agile Web Development with Rails中抄下来的一段代码:

def list
  @dynamic_content = Time.now.to_s
  unless read_fragment(:action => 'list')
    logger.info("Creating fragment")
    @articles = Article.find_recent
  end
end

相信大家也都是用判断read_fragment返回值的方法来看cache是否存在。但你有没有想过,read_fragment这个函数的真正含义是读取cache。在view里,helper cache又会把cache的内容读一遍。假设你的cache用的是FileStore,下面是cache.rb中read的实现:

def read(name, options = nil) #:nodoc:
    File.open(real_file_path(name), 'rb') { |f| f.read } rescue nil
end

两次调用read_fragment就是两次独立的文件IO,而且每次都全文读入。第一次读完全是浪费。
解决方案:
一个是在controller里将read_fragment返回的内容保存在一个instance variable里,再到view里显示。这个做法的缺点是无法在view里使用现成的helper cache了,需要重写一个类似的helper,判断相关的instance variable而不是再去read。
另一个方法是自己写一个check_fragment,用File.exist?去判断,效率要高得多。

cache判断的原子性问题

还是上面那段代码:

def list
  @dynamic_content = Time.now.to_s
  unless read_fragment(:action => 'list')
    logger.info("Creating fragment")
    @articles = Article.find_recent
  end
end

假设controller在read_fragment的时候,cache存在,但就在运行出了这个unless之后,render view之前,cache被清除了,这完全有可能发生。就在render view的时候,helper cache发现cache不存在,看看下面的代码(仍然来自Agile Web Development with Rails),会发生什么?

< % cache do %> < !- Here's the content we cache -> 
    < % for article in @articles -%>
  • < %= h(article.body) %>
  • < % end -%>
< % end %> < !- End of the cached content ->

@articles是nil,你的程序抛出异常了!
解决方法:
没有什么万灵药,要根据具体的情况来分析。最简单的就是在view里判断@articles是否存在,总比抛异常好。还有一个方法就是把@articles的初始化放到view的cache do里面,如果嫌view里代码太多,就放一个helper里。

可怕的expire_fragment

其实只有一半是可怕的。
如果用FileStore,expire_fragment做的其实就是删除文件。假如你指定了是哪个cache,没问题。但expire_fragment还支持正则表达式,假如你指定的是一个正则表达式,它会干什么?看action_controller/caching.rb里的实现:

def delete_matched(matcher, options) #:nodoc:
  search_dir(@cache_path) do |f|
    if f =~ matcher
      begin
        File.delete(f)
      rescue Object => e
        # If there's no cache, then there's nothing to complain about
      end
    end
  end
end
...
def search_dir(dir, &callback)
  Dir.foreach(dir) do |d|
    next if d == "." || d == ".."
    name = File.join(dir, d)
    if File.directory?(name)
      search_dir(name, &callback)
    else
      callback.call name
    end
  end
end

遍历所有cache目录下的文件,所有。然后一个一个去和正则表达式匹配。如果你不幸有上千个cache fragment,知道有多慢么?自己试试吧。
解决方案:
首先不是一个解决方案,是一个忠告:不要传给expire_fragment一个正则表达式。
其次,你可能可以写一个expire_fragment_from(root_path, reg),让它从一个指定的路径下做这样的遍历。然后优化cache fragment的目录结构,尽量缩小作用域。

Ruby On Rails是一个很好的框架,但仍然有要改进的地方。如果你在使用rails cache上有什么心得,欢迎交流。

7 Comments :, , more...

Search

Archives

Browse by tags

agile apple blog book design ecto extension firefox git google hack ichm iphone keyword life mac madfox movie nonsense opensource plugin pm ruby rubyonrails sns software startup wordpress work 财帮子