石锅拌饭

再谈rails缓存机制的问题

by Robin Lu on Jan.31, 2008, about , ,

同样,我们还是从一个典型的缓存操作开始。很多时候,当我们创建或者修改了一个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 for this entry

  • hideto

    实际上和你在“memcached使用中的竞争条件中所说的一样”,一直不使用Cache.delete清空缓存而是在数据更新后使用Cache.put直接更新缓存即可,这样即使ActiveRecord的transaction包容了不该放在transaction里的东西(这个东西由“清空缓存”变成了“更新缓存”)也没关系啊呵呵

  • Robin Lu

    你提到的方法的确可以做为一种workaround,但这样的workaround可能有这样一些个问题:

    一是需要自己实现一套修改更新的互斥机制。memcached已经有这样的互斥机制,而rails cache没有。

    二是它仍然无法保证ACID,当数据保存在更新缓存后出错,然后rollback了,数据并没有真的更新,而缓存却变成别的内容了,你还需要hook rollback_db_transaction,仍然需要记录曾经更新的内容,可能比把更新和删除缓存的操作拿出transaction更复杂。

  • hideto

    最近我们开发项目时也遇到一个场景和这个非常类似
    我们需要在after_save中做一些操作,但是希望在事务提交后执行

    后来发现有段after_commit代码可以实现这个目标呵呵

  • Robin Lu

    hideto,我google了一下,搜到这个after_commit, http://elimiller.blogspot.com/2007/06/proper-cache-expiry-with-aftercommit.html, 这个是你们在用的么?
    从代码上看,他用的是我文章里提到的第一种方案,没有考虑到transaction嵌套,是有问题的。

  • 金色葡萄

    这是一则公益spam:
    正月十五是合法燃放烟花爆竹的最后一天,这一天人们会把所有留存的烟花爆竹全部放掉。
    请务必注意安全,最好使用护目镜防护!!
    往年在正月十五这一天受伤的人比年三十还多。
    打扰了,请在正月十六删除吧。

  • 老王

    我觉得Rails本身的事务处理就是问题的,在ActiveRecord对象上加入事务功能不管怎么说也不对吧,正确的位置应该是Controller的Action上,因为它勾画了事务的自然边界。

  • Robin Lu

    这里讨论的是built-in transaction,目的是把可预知的transaction先为你加上,能省一点事就省一点事,也算符合DRY的原则。的确更多的transaction应该是controller里控制的,但情况就千变万化了,做成built-in恐怕更不方便更找骂。

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 财帮子