石锅拌饭

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中抄下来的一段代码:

1
2
3
4
5
6
7
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的实现:

1
2
3
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判断的原子性问题

还是上面那段代码:

1
2
3
4
5
6
7
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),会发生什么?

1
2
3
4
5
6
7
< % cache do %> < !- Here's the content we cache -> 
  <ul> 
    < % for article in @articles -%> 
      <li><p>< %= h(article.body) %></p></li> 
    < % end -%> 
  </ul> 
< % 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里的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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, &amp;callback)
  Dir.foreach(dir) do |d|
    next if d == "." || d == ".."
    name = File.join(dir, d)
    if File.directory?(name)
      search_dir(name, &amp;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...

Archives

Browse by tags