我也简单谈下《Web应用的缓存设计模式》
拜读了Robbin的文章《Web应用的缓存设计模式》http://robbinfan.com/blog/38/orm-cache-sumup ,我觉得大体思想还是值得学习和借鉴的,借这机会顺便简单谈谈我一般的做法,基于它文章Blog的例子和场景。
以读取博客文章列表和文章为例
一、数据库设计
首先,从数据库设计上,我赞同Contents拆分出去,在显示列表时,是没必要读取完整内容的。但如果缓存应用得当,这个可以属于可选项,并非必须。按照我的习惯,表设计会如下:
BlogId | int | 用以存储博客内容,表主键,聚集索引 |
Title | nvarchar(256) | 博客标题 |
Content | nvarchar(MAX) | 博客内容 |
FormattedContent | nvarchar(MAX) | 格式化后博客内容,空间换时间,没必要消耗CPU去格式化markdown。可选项,也可以运算后放缓存 |
AuthorId | int | 和Accounts表关联 |
Author | nvarchar(256) | 作者,冗余字段,可以不必查询Accounts表 |
BlogDate | datetime | 博客发布时间 |
补充说明:
1. 适当冗余,例如FormattedContent和Author字段,减少跨表查询或CPU运算
2. Content和FormattedContent字段可以考虑分离到另一个表纵向拆分,但并非必要选项,这里我没有分开,尽可能在一起
3. 该表可以用非关系数据库如Key-Value数据库存储,当数据增大可以根据BlogId进行合理分区,这样可以避免单表过大影响查询效率。对WindowsAzure了解的可参考TableStorage。
光这个表,博客的内容是都有了,但要达到比较高的效率,我还会根据业务场景建立几个“索引表”,举两个业务场景:1,首页分页浏览博客文章列表 2,根据Tag检索博客文章,这两个场景会对应两个“索引表”,如下所示:
BlogId | int | 博客Id,和Blogs表的BlogId关联,主键,非聚集索引 |
BlogDate | datetime | 博客发布时间,和Blogs表的BlogDate一致,聚集索引 |
Tag | nvarchar(64) | Tag字段。和BlogId字段形成双主键,非聚集索引 |
BlogId | int | 博客Id,和Blogs表的BlogId关联 |
BlogDate | datetime | 博客发布时间,和Blogs表的BlogDate一致。Tag和BlogDate作为聚集索引 |
补充说明:
1. 所谓索引表,其实功能和索引类似的,就是根据查询条件查出来实体对象主键(BlogId)。
2. 聚集索引尽量建立在查询条件上,以尽可能提高检索效率
3. 对于性能要求高的查询单独索引表
4. 对索引表的查询结果为满足条件主键集合,例如:“select BlogId from BlogList order by BlogDate desc” or "select BlogId from TagBlogs where Tag = @Tag order by BlogDate desc"
表设计基本如此
二、数据库查询
1. 根据id集合返回实体集合,不提供单个id查询。存储过程应该有很多种写法,类似于 “select BlogId, Title, Content, FormattedContent, AuthorId, Author, BlogDate from Blogs where BlogId in (2, 4, 7, 12, 88)”
2. 根据业务场景去查id集合,例如:“select BlogId from BlogList order by BlogDate desc” or "select BlogId from TagBlogs where Tag = @Tag order by BlogDate desc"
3. 涉及分页在内存中完成。例如我要按照时间倒序查所有博客列表,那么根据上面的Sql会得到一个排序好的BlogId集合,转换成内存的一个数组,例如:[100,99,98,97,96....5,4,3,2,1],如果我要现在每页5条纪录,那么第一页的集合就是:[100,99,98,97,96]。然后根据把它作为一个id集合,就可以获取到一个实体集合
这样把数据库查询分成两次,确实有脱裤子放屁之嫌,但结合缓存来看就不会了。
三、缓存
缓存的粒度确实是个非常重要的问题,举例来说,如果按照“SELECT * FROM blogs ORDER BY id DESC LIMIT 20”来查询一页博客数据集合。并将它缓存到内存中,那么如果博客更新频繁(象博客园这样的频度),那么缓存命中率极低,所以我一般会把缓存分成两级。
1. 第一级,id->entity,也就是粒度为单个实体,举博客的例子,就是Key是blogid,Value就是Blog实体对象。这个需要有一个Cache更新、清理的逻辑,有很多成熟方案,不赘述。
2. 第二级,查询条件->ids,也就是查询条件到id集合的一个缓存,举例来说,根据Tag查询博客,那么我会把Tag作为Key,Value则为该Tag下按照时间倒序排列的所有BlogId的集合。这个根据数据更新频度,一般Cache时间很短,这样可以保证数据更新了缓存也会及时更新,并且它都是在索引表查询,数据库查询效率极高。
根据上面二级缓存的设计,缓存命中率会大大提高,例如即使帖子列表更新,一级缓存也不会实效。二级缓存其实只是辅助,但是由于索引表的存在,不用担心查询效率降低对缓存的依赖。
四、综合
综合以上种种,一个结合缓存的数据查询会如下过程(以按照时间倒序分页浏览博客列表为例):
1. 首先根据业务条件去缓存查找id集合,如果缓存没有,就去数据库查询,并将查询结果更新到缓存。假如我获得的id集合为:[100,99,98,97,96....5,4,3,2,1]
2. 根据分页条件,在内存中从返回的所有id集合中获取页id集合,假如页id集合是[100,99,98,97,96]
3. 根据页id集合中的id,去一级缓存中逐一检查缓存是否有数据,过滤掉缓存中有的。例如[100,99,98,97,96]中[100,97,96]三个在Cache中已经存在,那么留下[99,98]
4. 用过滤后的id集合(如果为空跳过该步骤)去数据库中获取实体集合,并将结果加入一级缓存。例如“select BlogId, Title, Content, FormattedContent, AuthorId, Author, BlogDate from Blogs where BlogId in (99,98)”,并将它加入缓存
5. 根据id结合顺序拼合实体集合返回,例如:[blog100,blog99,blog98,blog87,blog96]
6. 如果分页控件需要知道总记录条数,将第一步的总id集合条数返回即可。
从单次查询来看,一次查询分成了两次,效率不高,但是从多次查询请求来看,缓存命中率会非常高,对数据库压力极小,数据库的查询将主要集中在对id集合的查询,但是由于索引表的存在,这个查询性能将极高。
四、总结
以上是我常用的一种数据查询和缓存的方案,由于解释起来较为繁琐,所以一直懒得写,但是实际用下来无论是查询效率还是开发效率都极高效。希望能对大家有所帮助。
另外,这种方案不适用于数据量极大的情况,因为这种情况下获取全部id集合的成本极高,但适合绝大部分应用场景。