javaeye3/web_cache_strategy.md

20 KiB
Raw Permalink Blame History

Web应用的缓存设计模式

ORM缓存引言

从2003年开始在Web应用领域ORM(对象-关系映射)框架就开始逐渐普及并且流行开来其中最广为人知的就是Java的开源ORM框架Hibernate后来Hibernate也成为了EJB3的实现框架2005年以后ORM开始普及到其他编程语言领域其中最有名气的是Ruby on rails框架的ORM ActiveRecord。如今各种开源框架的ORM乃至ODM(对象-文档关系映射用在访问NoSQLDB)层出不穷,功能都十分强大,也很普及。

然而围绕ORM的性能问题也一直有很多批评的声音。其实ORM的架构对插入缓存技术是非常容易的我做的很多项目和产品但凡使用ORM缓存都是标配性能都非常好。而且我发现业界使用ORM的案例都忽视了缓存的运用或者说没有意识到ORM缓存可以带来巨大的性能提升。

ORM缓存应用案例

我们去年有一个老产品重写的项目这个产品有超过10年历史了数据库的数据量很大多个表都是上千万条记录最大的表记录达到了9000万条Web访问的请求数每天有300万左右。

老产品采用了传统的解决性能问题的方案Web层采用了动态页面静态化技术超过一定时间的文章生成静态HTML文件对数据库进行分库分表按年拆表。动态页面静态化和分库分表是应对大访问量和大数据量的常规手段本身也有效。但它的缺点也很多比方说增加了代码复杂度和维护难度跨库运算的困难等等这个产品的代码维护历来非常困难导致bug很多。

进行产品重写的时候我们放弃了动态页面静态化采用了纯动态网页放弃了分库分表直接操作千万级乃至近亿条记录的大表进行SQL查询也没有采取读写分离技术全部查询都是在单台主数据库上进行数据库访问全部使用ActiveRecord进行了大量的ORM缓存。上线以后的效果非常好单台MySQL数据库服务器CPU的IO Wait低于5%用单台1U服务器2颗4核至强CPU已经可以轻松支持每天350万动态请求量最重要的是插入缓存并不需要代码增加多少复杂度可维护性非常好。

总之采用ORM缓存是Web应用提升性能一种有效的思路这种思路和传统的提升性能的解决方案有很大的不同但它在很多应用场景(包括高度动态化的SNS类型应用)非常有效而且不会显著增加代码复杂度所以这也是我自己一直偏爱的方式。因此我一直很想写篇文章结合示例代码介绍ORM缓存的编程技巧。

今年春节前后我开发自己的个人网站项目有意识的大量使用了ORM缓存技巧。对一个没多少访问量的个人站点来说有些过度设计了但我也想借这个机会把常用的ORM缓存设计模式写成示例代码提供给大家参考。

ORM缓存的基本理念

我在2007年的时候写过一篇文章分析ORM缓存的理念ORM对象缓存探讨 所以这篇文章不展开详谈了总结来说ORM缓存的基本理念是

  • 以减少数据库服务器磁盘IO为最终目的而不是减少发送到数据库的SQL条数。实际上使用ORM会显著增加SQL条数有时候会成倍增加SQL。
  • 数据库schema设计的取向是尽量设计_细颗粒度_的表表和表之间用外键关联颗粒度越细缓存对象的单位越小缓存的应用场景越广泛
  • 尽量避免多表关联查询,尽量拆成多个表单独的主键查询,尽量多制造 n + 1 条查询,不要害怕“臭名昭著”的 n + 1 问题,实际上 n + 1 才能有效利用ORM缓存

利用表关联实现透明的对象缓存

在设计数据库的schema的时候设计多个细颗粒度的表用外键关联起来。当通过ORM访问关联对象的时候ORM框架会将关联对象的访问转化成用主键查询关联表发送 n + 1条SQL。而基于主键的查询可以直接利用对象缓存。

我们自己开发了一个基于ActiveRecord封装的对象缓存框架second_level_cache 从这个ruby插件的名称就可以看出实现借鉴了Hibernate的二级缓存实现。这个对象缓存的配置和使用可以看我写的ActiveRecord对象缓存配置

下面用一个实际例子来演示一下对象缓存起到的作用:访问我个人站点的首页。 这个页面的数据需要读取三张表blogs表获取文章信息blog_contents表获取文章内容accounts表获取作者信息。三张表的model定义片段如下完整代码请看models

class Account < ActiveRecord::Base
  acts_as_cached
  has_many :blogs
end

class Blog < ActiveRecord::Base
  acts_as_cached
  belongs_to :blog_content, :dependent => :destroy 
  belongs_to :account, :counter_cache => true
end

class BlogContent < ActiveRecord::Base
  acts_as_cached
end

传统的做法是发送一条三表关联的查询语句,类似这样的:

SELECT blogs.*, blog_contents.content, account.name 
	FROM blogs 
	LEFT JOIN blog_contents ON blogs.blog_content_id = blog_contents.id 
	LEFT JOIN accounts ON blogs.account_id = account.id

往往单条SQL语句就搞定了但是复杂SQL的带来的表扫描范围可能比较大造成的数据库服务器磁盘IO会高很多数据库实际IO负载往往无法得到有效缓解。

我的做法如下,完整代码请看home.rb

@blogs = Blog.order('id DESC').page(params[:page])

这是一条分页查询实际发送的SQL如下

SELECT * FROM blogs ORDER BY id DESC LIMIT 20

转成了单表查询磁盘IO会小很多。至于文章内容则是通过blog.content的对象访问获得的由于首页抓取20篇文章所以实际上会多出来20条主键查询SQL访问blog_contents表。就像下面这样

DEBUG -  BlogContent Load (0.3ms)  SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 29 LIMIT 1
DEBUG -  BlogContent Load (0.2ms)  SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 28 LIMIT 1
DEBUG -  BlogContent Load (1.3ms)  SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 27 LIMIT 1
......
DEBUG -  BlogContent Load (0.9ms)  SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 10 LIMIT 1

但是主键查询SQL不会造成表的扫描而且往往已经被数据库buffer缓存所以基本不会发生数据库服务器的磁盘IO因而总体的数据库IO负载会远远小于前者的多表联合查询。特别是当使用对象缓存之后会缓存所有主键查询语句这20条SQL语句往往并不会全部发生特别是热点数据缓存命中率很高

DEBUG -  Cache read: robbin/blog/29/1
DEBUG -  Cache read: robbin/account/1/0
DEBUG -  Cache read: robbin/blogcontent/29/0
DEBUG -  Cache read: robbin/account/1/0
DEBUG -  Cache read: robbin/blog/28/1
......
DEBUG -  Cache read: robbin/blogcontent/11/0
DEBUG -  Cache read: robbin/account/1/0
DEBUG -  Cache read: robbin/blog/10/1
DEBUG -  Cache read: robbin/blogcontent/10/0
DEBUG -  Cache read: robbin/account/1/0

按照column拆表实现细粒度对象缓存

数据库的瓶颈往往在磁盘IO上所以应该尽量避免对大表的扫描。传统的拆表是按照row去拆分保持表的体积不会过大但是缺点是造成应用代码复杂度很高使用ORM缓存的办法则是按照column进行拆表原则一般是

  • 将大字段拆分出来,放在一个单独的表里面,表只有主键和大字段,外键放在主表当中
  • 将不参与where条件和统计查询的字段拆分出来放在独立的表中外键放在主表当中

下面看一个实际的例子文章的内容content字段是一个大字段该字段不能放在blogs表中否则会造成blogs表过大表扫描造成较多的磁盘IO。我实际做法是创建blog_contents表保存content字段schema简化定义如下

CREATE TABLE `blogs` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `blog_content_id` int(11) NOT NULL,
  `content_updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
);

CREATE TABLE `blog_contents` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `content` mediumtext NOT NULL,
  PRIMARY KEY (`id`)
);

blog_contents表只有content大字段其外键保存到主表blogs的blog_content_id字段里面。

model定义和相关的封装如下

class Blog < ActiveRecord::Base
  acts_as_cached
  delegate :content, :to => :blog_content, :allow_nil => true
  
  def content=(value)
    self.blog_content ||= BlogContent.new
    self.blog_content.content = value
    self.content_updated_at = Time.now
  end
end

class BlogContent < ActiveRecord::Base
  acts_as_cached
  validates :content, :presence => true
end    

在Blog类上定义了虚拟属性content当访问blog.content的时候实际上会发生一条主键查询的SQL语句获取blog_content.content内容。由于BlogContent上面定义了对象缓存acts_as_cached只要被访问过一次content内容就会被缓存到memcached里面。

这种缓存技术实际会非常有效,因为: 只要缓存足够大,所有文章内容可以全部被加载到缓存当中,无论文章内容表有多么大,你都不需要再访问数据库了 更进一步的是: 这张大表你永远都只需要通过主键进行访问,绝无可能出现表扫描的状况 为何当数据量大到9000万条记录以后我们的系统仍然能够保持良好的性能秘密就在于此。

还有一点非常重要: 使用以上两种对象缓存的设计模式,你除了需要添加一条缓存声明语句acts_as_cached以外,不需要显式编写一行代码 有效利用缓存的代价如此之低,何乐而不为呢?

以上两种缓存设计模式都不需要显式编写缓存代码,以下的缓存设计模式则需要编写少量的缓存代码,不过代码的增加量非常少。

写一致性缓存

写一致性缓存叫做write-through cache是一个CPU Cache借鉴过来的概念意思是说当数据库记录被修改以后同时更新缓存不必进行额外的缓存过期处理操作。但在应用系统中我们需要一点技巧来实现写一致性缓存。来看一个例子

我的网站文章原文是markdown格式的当页面显示的时候需要转换成html的页面这个转换过程本身是非常消耗CPU的我使用的是Github的markdown的库。Github为了提高性能用C写了转换库但如果是非常大的文章仍然是一个耗时的过程Ruby应用服务器的负载就会比较高。

我的解决办法是缓存markdown原文转换好的html页面的内容这样当再次访问该页面的时候就不必再次转换了直接从缓存当中取出已经缓存好的页面内容即可极大提升了系统性能。我的网站文章最终页的代码执行时间开销往往小于10ms就是这个原因。代码如下

def md_content  # cached markdown format blog content
  APP_CACHE.fetch(content_cache_key) { GitHub::Markdown.to_html(content, :gfm) }
end

这里存在一个如何进行缓存过期的问题,当文章内容被修改以后,应该更新缓存内容,让老的缓存过期,否则就会出现数据不一致的现象。进行缓存过期处理是比较麻烦的,我们可以利用一个技巧来实现自动缓存过期:

def content_cache_key
  "#{CACHE_PREFIX}/blog_content/#{self.id}/#{content_updated_at.to_i}"
end

当构造缓存对象的key的时候我用文章内容被更新的时间来构造key值这个文章内容更新时间用的是blogs表的content_updated_at字段当文章被更新的时候blogs表会进行update更新该字段。因此每当文章内容被更新缓存的页面内容的key就会改变应用程序下次访问文章页面的时候缓存就会失效于是重新调用GitHub::Markdown.to_html(content, :gfm)生成新的页面内容。 而老的页面缓存内容再也不会被应用程序存取根据memcached的LRU算法当缓存填满之后将被优先剔除。

除了文章内容缓存之外文章的评论内容转换成html以后也使用了这种缓存设计模式。具体可以看相应的源代码models

片段缓存和过期处理

Web应用当中有大量的并非实时更新的数据这些数据都可以使用缓存避免每次存取的时候都进行数据库查询和运算。这种片段缓存的应用场景很多例如

  • 展示网站的Tag分类统计(只要没有更新文章分类,或者发布新文章,缓存一直有效)
  • 输出网站RSS(只要没有发新文章,缓存一直有效)
  • 网站右侧栏(如果没有新的评论或者发布新文章,则在一段时间例如一天内基本不需要更新)

以上应用场景都可以使用缓存,代码示例:

def self.cached_tag_cloud
  APP_CACHE.fetch("#{CACHE_PREFIX}/blog_tags/tag_cloud") do
    self.tag_counts.sort_by(&:count).reverse
  end
end

对全站文章的Tag云进行查询对查询结果进行缓存

<% cache("#{CACHE_PREFIX}/layout/right", :expires_in => 1.day) do %>

<div class="tag">
  <% Blog.cached_tag_cloud.select {|t| t.count > 2}.each do |tag| %>
  <%= link_to "#{tag.name}<span>#{tag.count}</span>".html_safe, url(:blog, :tag, :name => tag.name) %>
  <% end %>
</div>
......
<% end %>

对全站右侧栏页面进行缓存过期时间是1天。

缓存的过期处理往往是比较麻烦的事情但在ORM框架当中我们可以利用model对象的回调很容易实现缓存过期处理。我们的缓存都是和文章以及评论相关的所以可以直接注册Blog类和BlogComment类的回调接口声明当对象被保存或者删除的时候调用删除方法

class Blog < ActiveRecord::Base
  acts_as_cached
  after_save :clean_cache
  before_destroy :clean_cache
  def clean_cache
    APP_CACHE.delete("#{CACHE_PREFIX}/blog_tags/tag_cloud")   # clean tag_cloud
    APP_CACHE.delete("#{CACHE_PREFIX}/rss/all")               # clean rss cache
    APP_CACHE.delete("#{CACHE_PREFIX}/layout/right")          # clean layout right column cache in _right.erb
  end
end

class BlogComment < ActiveRecord::Base
  acts_as_cached
  after_save :clean_cache
  before_destroy :clean_cache
  def clean_cache
    APP_CACHE.delete("#{CACHE_PREFIX}/layout/right")     # clean layout right column cache in _right.erb
  end
end  

在Blog对象的after_savebefore_destroy上注册clean_cache方法当文章被修改或者删除的时候删除以上缓存内容。总之可以利用ORM对象的回调接口进行缓存过期处理而不需要到处写缓存清理代码。

对象写入缓存

我们通常说到缓存,总是认为缓存是提升应用读取性能的,其实缓存也可以有效的提升应用的写入性能。我们看一个常见的应用场景:记录文章点击次数这个功能。

文章点击次数需要每次访问文章页面的时候都要更新文章的点击次数字段view_count然后文章必须实时显示文章的点击次数因此常见的读缓存模式完全无效了。每次访问都必须更新数据库当访问量很大以后数据库是吃不消的因此我们必须同时做到两点

  • 每次文章页面被访问,都要实时更新文章的点击次数,并且显示出来
  • 不能每次文章页面被访问,都更新数据库,否则数据库吃不消

对付这种应用场景我们可以利用对象缓存的不一致来实现对象写入缓存。原理就是每次页面展示的时候只更新缓存中的对象页面显示的时候优先读取缓存但是不更新数据库让缓存保持不一致积累到n次直接更新一次数据库但绕过缓存过期操作。具体的做法可以参考blog.rb

# blog viewer hit counter
def increment_view_count
  increment(:view_count)        # add view_count += 1
  write_second_level_cache      # update cache per hit, but do not touch db
                                # update db per 10 hits
  self.class.update_all({:view_count => view_count}, :id => id) if view_count % 10 == 0
end

increment(:view_count)增加view_count计数关键代码是第2行write_second_level_cache更新view_count之后直接写入缓存但不更新数据库。累计10次点击再更新一次数据库相应的字段。另外还要注意如果blog对象不是通过主键查询而是通过查询语句构造的要优先读取一次缓存保证页面点击次数的显示一致性因此 _blog.erb 这个页面模版文件开头有这样一段代码:

<% 
  # read view_count from model cache if model has been cached.
  view_count = blog.view_count
  if b = Blog.read_second_level_cache(blog.id)
    view_count = b.view_count
  end
%>

采用对象写入缓存的设计模式就可以非常容易的实现写入操作的缓存在这个例子当中我们仅仅增加了一行缓存写入代码而这个时间开销大约是1ms就可以实现文章实时点击计数功能是不是非常简单和巧妙实际上我们也可以使用这种设计模式实现很多数据库写入的缓存功能。

常用的ORM缓存设计模式就是以上的几种本质上都是非常简单的编程技巧代码的增加量和复杂度也非常低只需要很少的代码就可以实现但是在实际应用当中特别是当数据量很庞大访问量很高的时候可以发挥惊人的效果。我们实际的系统当中缓存命中次数:SQL查询语句一般都是5:1左右即每次向数据库查询一条SQL都会在缓存当中命中5次数据主要都是从缓存当中得到而非来自于数据库了。

其他缓存的使用技巧

还有一些并非ORM特有的缓存设计模式但是在Web应用当中也比较常见简单提及一下

用数据库来实现的缓存

在我这个网站当中每篇文章都标记了若干tag而tag关联关系都是保存到数据库里面的如果每次显示文章都需要额外查询关联表获取tag显然会非常消耗数据库。在我使用的acts-as-taggable-on插件中它在blogs表当中添加了一个cached_tag_list字段保存了该文章标记的tag。当文章被修改的时候会自动相应更新该字段避免了每次显示文章的时候都需要去查询关联表的开销。

HTTP客户端缓存

基于资源协议实现的HTTP客户端缓存也是一种非常有效的缓存设计模式我在2009年写过一篇文章详细的讲解了所以这里就不再复述了。

用缓存实现计数器功能

这种设计模式有点类似于对象写入缓存利用缓存写入的低开销来实现高性能计数器。举一个例子用户登录为了避免遭遇密码暴力破解我限定了每小时每IP只能尝试登录5次如果超过5次拒绝该IP再次尝试登录。代码实现很简单如下

post :login, :map => '/login' do
  login_tries = APP_CACHE.read("#{CACHE_PREFIX}/login_counter/#{request.ip}")
  halt 403 if login_tries && login_tries.to_i > 5  # reject ip if login tries is over 5 times
  @account = Account.new(params[:account])
  if login_account = Account.authenticate(@account.email, @account.password)
    session[:account_id] = login_account.id
    redirect url(:index)
  else
    # retry 5 times per one hour
    APP_CACHE.increment("#{CACHE_PREFIX}/login_counter/#{request.ip}", 1, :expires_in => 1.hour)
    render 'home/login'
  end
end

等用户POST提交登录信息之后先从缓存当中取该IP尝试登录次数如果大于5次直接拒绝掉如果不足5次而且登录失败计数加1显示再次尝试登录页面。

以上相关代码可以从这里获取:robbin_site