RavenDB Tips: 巧妙设计文档的Id格式以提升查询性能

1. RavenDB中的两种查询方式

在RavenDB中,查询可分为两种,一种是通过IDocumentSession的Load方法按Id直接查询,另一种是通过IDocumentSession的Query<T>方法查询索引。如下所示:

// 使用Load方法查询Id为users/1的User
var user = session.Load<User>("users/1");

// 使用Query<T>方法查询索引,索引名为UserIndex
var users = session.Query<User>("UserIndex");

这两种查询的主要区别在于后者是查询索引,而前者不是。RavenDB的索引是在后台计算的,这也就意味着查询索引得到的很可能是脏数据,例如,我们添加一个用户并成功保存,然后通过索引查询用户,这时是有可能查询不到刚刚保存的用户记录的。而对于Load方法,只要用户保存成功了,调用Load<User>(userId)则一定会查询到相应的用户记录,且不为脏数据。

2. 业务流程处理中对强一致性的要求

显然,RavenDB的高性能正是来源于索引查询的这种“最终一致性”,因为对于大部分应用来说(尤其互联网应用),几乎都是读多写少,而且可以接受临时的数据不一致。但对于业务流程的处理来说,往往要求数据是强一致的,例如,某系统中有User和Account两个类,前者表示用户,后者表示该用户相关联的帐号(Account中有Balance(表示帐户余额)这样的属性),User和Account是一对一的关系,并且这两个文档独立存储,也就是说不把Account存储为User文档的内嵌对象。现假设有一个用户购买商品的流程,购买商品需要支付商品费用,此时该用户(User)的帐户(Account)需要扣除相应的金额,用伪代码可表示为:

var user = ...;
var account = GetAccount(user);
account.Decrease(50); // 假设需要支付50元

上面代码的问题就在于GetAccount()方法要怎么写,对于这个业务流程来讲,调用GetAccount()时必须得到该用户最新的帐户信息,不能得到脏数据,如果我们在GetAccount()中通过查询索引来获取Account实例,则可能查不到Account,或查到的Account的Balance是脏数据,这是无法接受的。不过,前面说的其实并不完全正确,在RavenDB中查询索引,是可以强制不返回脏数据的(即等待索引计算结束后再返回),用代码来表示就是:

var user = ...;
var account = session.Query<Account>("AccountIndex")
                     .Where(x => x.UserId == user.Id)
                     .Customize(x => x.WaitForNonStaleResultsAsOfNow())
                     .First();

上面代码中的Customize(x => x.WaitForNonStaleResultsAsOfNow())会等待索引计算结束后再返回,这样得到的Account就可以保证是最新的,但这里有一个大问题:这个等待可能很快,也可能很慢。这也相当于把两个本来并行的操作改成同步执行,性能杀手!对于一个设计良好的RavenDB应用来说,WaitForNonStatleResultsAsOfNow()这个方法应该基本不使用,如果我们发现代码中大量调用了该方法,那我们的代码一定是有问题的(当然,在单元测试中则可能会大量用到该方法)。现在问题有了,就要找解决方案。

3. 强一致性和高性能兼得的方案

解决思路其实很明显,如果我们可以通过Load来加载Account,那问题便迎刃而解,而如果想通过Load来加载Account,那就需要一种通过User实例来计算Account的Id的办法,因为User是已知的,只要可以根据User中的信息来计算出Account的Id,那就可以通过Load来加载Account了,所以,解决方案就是:将Account的Id格式设计成UserId和/account的拼接。用代码表示如下:

创建用户:

// 创建User
var user = new User();
// ...
session.Store(user);
                
// 创建Account,注意Id的格式
var account = new Account();
account.Id = user.Id + "/account";
session.Store(account);

查询Account:

var user = session.Load<User>("已知的UserId");

// 通过User计算Account的Id
var accountId = user.Id + "/account";
// 通过计算出来的Account Id直接加载Account
var account = session.Load<Account>(accountId);

这样,我们的代码就不再需要WaitForNonStatleResultsAsOfNow()了,而且,上面的代码很容易进行进一步优化,例如将User和Account放在一个数据库请求中加载(Load方法中可以传入多个不同实体的Id同时加载),这样就可以减少一次数据库连接的性能消耗。

4. 一对多关联的处理

上面是一对一的情况,对于一对多的情况一样适用(当然这个“多”不能太多,一般就几条为宜),假设一个User可以关联多个Account(关联的Account总数不多),这时可以将Account的Id格式设计成: UserId + /account/ + 数字,例如Id为users/1的用户关联的Account Id可以有users/1/account/1, users/1/account/2等,在这种情况下,加载指定用户的Account可以利用IDocumentSession的Advanced.LoadStartingWith<T>()方法。

RavenDB服务端的Id默认是users/11、products/23、orders/12这样的格式,而客户端则可通过约定来支持整型的Id,也就是说,我们可以在程序中将User的Id定义为Int32类型(这只是约定,RavenDB的客户端类库会实现客户端Int32 Id到服务端的字符串Id的转换),但通过上面的例子也可以看出,在RavenDB中更推荐直接使用字符串格式的Id,因为我们可以在字符串Id的格式上做很多文章。

另外,通过上面关于查询的讨论也可以看到,想通过网上广传的Repository模式来让应用可同时支持RDBMS和RavenDB的做法是不可行的,RDBMS的查询可以返回强一致的结果,而RavenDB中的索引查询则是最终一致的,若要让Repository中的查询接口返回强一致的结果,则要使用WaitForNonStatleResults(),而这会对性能产生很大影响。

posted @ 2012-09-23 22:37  水言木  阅读(2471)  评论(0编辑  收藏  举报