再谈rails缓存机制的问题

同样,我们还是从一个典型的缓存操作开始。很多时候,当我们创建或者修改了一个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 thoughts on “再谈rails缓存机制的问题

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

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

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

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

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

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

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

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

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

Comments are closed.