翻译了几年前的一篇文章,思想很不错。
http://www.jroller.com/kenwdelong/entry/horizontal_database_partitioning_with_spring
简介
大约在一年以前,我决定水平扩展我们的数据库。在我们的数据库中我们拥有数百万的用户,我们期望我们的用户为我们的网站生成更多的内容,同时我们将收集更多的用户行为。我们已经被垂直扩展的策略搞得焦头烂额,我们越来越难于对硬件进行扩展,你只能同时扩展一两个硬件,并且当它挂掉时,所有的东西都崩溃掉了。因此,我们决定使用普通硬件设备对数据库进行水平扩展。
一个专门从事Mysql数据库的可扩展性方面研究的顾问建议我们,基于用户进行水平分区:一个用户及其所有数据(人物概况,用户生成的内容等等)将存在某一个分区上。一个全局用户数据库(GLUD)将会是这组数据库的主键,GLUD将存储每个用户的主键和这个用户所在的那个分区的ID。
我们继续。我们最初的想法是为每个分区创建一个Hibernate的session factory。假设我们有两个用户数据库,user1和user2.那么我们将有两个session factories,每个数据库对应一个。使用这些数据库的Service(例如ProfileService)将会为每个数据库创建一个实例。Profile1Service将关联到使用use1SessionFactory的profile1Dao.对于N个分区的类似。调用该Service将触发一个Spring aop的拦截器,它将获取该用户的标识符,查询GLUD来决定该用户的数据存在哪个分区上,随后将会转发调用到正确的ProfileService实例上。
我们实现了一个这种方式的原型,并且它运行良好。随后我们遇到了两个想法。第一个是Interface21's Mark Fisher的博客所介绍的AbstractRoutingDataSource。第二个是Hibernate shards项目。第一种方式我们只需要创建一个ProfileService,一个ProfileDao以及一个UserSessionFactory,并让datasource知道有多个用户数据库。Hibernate shards是一个项目,其运行原理和我们最初的那个想法类似,为每个数据库创建一个session factory实例。
我们倾向于使用hibernate shards,这样就不用编写我们自己的分区系统。但是hibernate shards目前只发布了测试版。最好我们由于以下几个原因放弃使用hibernate shards:我们观察了数周,但是只有极少的人活跃在hibernate shards的社区。在我们核心基础设施使用如此新和不确定的项目使得我们没有安全感。第二,多session factories的策略本来就不具备扩展性:你需要为你的每个新加入的分区生成一个session factory。如果你变得像myspace那样成功,你将需要上百个session factory。根据文献所说的那样,多session factories是资源密集型应用(消耗大量的资源),这一点我们不会觉得舒服(这也是我们上面的想法的硬伤)。最后,我们查询了hibernate shards的文档,它对于如何与spring集成和配置的说明并不清楚,那么,spring的localSessionFactoryBean将无法工作。我不太喜欢深入spring的事务基础设施来创建一个ShardsSessionFactoryBean来合适地集成这种想法的事务管理。因此,我们决定采用routing-datasource的方法。
实现
我将带领你思考我们如何实现,已经它的优点和不足。首先是GLUD数据库。这个数据库包含了master_user表,他包含了在所有分区的所有用户的主键和邮箱地址。事实上,它包括了一个用户的所有唯一约束属性,也是数据库的唯一性约束可以应用的地方,但是在这里我们假设我们以email作为唯一约束。给定一个用户的email地址,master_user表可以用于定位用户表的主键。另外一个表示partition_map,它包括了一个用户主键的hash到一个分区id的映射。所以,如果你有一个用户的主键,那么就可以在partition_map中查找分区。我们所使用的hash函数是主键的最后三位数字,随后我们分配一千个虚拟分区。物理分区的数目可以是1到1000之间。例如,如果你只有两个物理分区,那么你可以映射分区000-499到user database 1,500-999到user database 2(或者你可以采用奇偶数的方法)。现在的问题是,你有用户的主键和email地址,你能够知道用户数据的数据库的分区id。
那么,谁来负责做分区定位的计算呢?我们编写了一个spring aop的拦截器来包装所有用于我们的分区数据库的services。拦截器可以使用GLUD数据库(通过中间的GludService)来确定路由到哪个分区。最后问题在于拦截器如何知道目前的操作关联到哪个用户。因此我们约定,每个方法的第一个参数可以识别用户:它应该是用户对象本身或者是用户主键或email。以上的这些将会帮助我们确定数据存在哪个分区。然而这是有漏洞的:在现有的分区系统中,使用分区数据库的service只能使用愚蠢的方法签名,它们不是类型安全的。下面是拦截器中方法的大致实现:
public Object selectExistingPartitionWithUser(ProceedingJoinPoint jp, LocatePreexistingUser annotation, User user) throws Throwable
{
GludEntry gludEntry = getGludService().getGludEntryForExistingUser(user);
int partitionNumber = gludEntry.getDatabasePartition();
datasourceNumberCache.set(partitionNumber);
Object returnValue = null;
try
{
returnValue = jp.proceed();
}
finally
{
datasourceNumberCache.remove();
}
return returnValue;
}
这里datasourceNumberCache是一个pubilc static final ThreadLocal<Integer>,它维护了本次操作所关联的用户所在的分区id。谁将读取这个ThreadLocal将在后文介绍。
我们使用了AspectJ切入点语言来描述我们的切入点(pointcut).这将使得我们可以为我们的拦截器使用类型安全的方法签名,正如你在上文看到的那样(没有Method对象或者Object对象这样的参数)。我们也发现许多不同类型的拦截也是必要的。上文我们看到了最简单的情况,寻找与用户关联的数据。但是当用户更新他的email(或者别的存在GLUD中的唯一性字段)?那么新建一个用户呢?某个操作需要广播到所有分区呢(计算上周某个用户创建的内容总数)?如果我们需要加载所有用户生成的内容来进行索引,批量加载呢?以上所有操作都要求在拦截器中编写不同的方法。如何使拦截器绑定不同的方法到不同的service呢?
对于这种情况我们使用了注解。你可以看到注解实例通过spring集成设施进入到方法签名之上。下面你就可以看到方法的切入点:
spring的官方文档和AspectJ的官网来完全理解这段代码的意思,当然它主要指绑定到所有以LocatePreexistingUser为注解的方法,和以User 对象作为第一参数的方法。argNames项是必要的,它能正确获得注解并传递User对象。我记得曾经比较糟糕的是当多于一个参数绑定到切入点上时,那很难运行正常,直到我偶然发现argNames参数。
@Around(value="@annotation(annotation) && args(user, ..)", argNames="annotation,user")
你可以通过
使用注解很爽的地方是你可以将数据从被注解的方法传递到拦截器。例如,下面是如上注解的定义:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface LocatePreexistingUser
{
public UserIdentifier userIdentifier() default USER_OBJECT;
public boolean userUpdate() default false;
}
在这里, UserIdentifier是一个值为USER_OBJECT、EMAIL、USER_PK的枚举,如果你更新GLUD中具有唯一约束的字段,如email,你可以在你的ProfileService中使用如下注解:
@LocatePreexistingUser(userUpdate=true)
public void updateEmail(User user, String newEmail)
{ ... }
那么,拦截器将像下面那样:
if(annotation.userUpdate)
{
// tell GLUD service to update its master_user record
}
这的确很nice。你可以传递信息到拦截器,它将告诉拦截器如何处理该方法的调用,并且注解指定了正确的方法定义。同样的,我认为这很nice。
当hibernate已经准备好发生sql到数据库了,它调用datasource获得一个连接。PartitionRoutingDataSource从ThreadLocal中读取出分区id,然后返回指向该数据库的连接。它继承了Spring的AbstractRoutingDataSource,其代码大致如下:
protected Object determineCurrentLookupKey()
{
Integer datasourceNumber =
DatasourceSwitchingAspect.datasourceNumberCache.get();
return datasourceNumber;
}
Spring的配置中配置了两个普通的数据源(以两个分区为例子),user1DataSource和user2DataSource。它们是标准的数据源指向物理的数据库(使用jboss连接池,通过jndi查找)。那么,我们供给Hibernate session factory的数据源配置应该是这样的:
<bean id="userDataSource" class="PartitionRoutingDataSource"> <property name="targetDataSources"> <map key-type="java.lang.Integer"> <entry key="1" value-ref="user1DataSource"/> <entry key="2" value-ref="user2DataSource"/> </map> </property> </bean>
Spring创建的session factory应该是这样的:
<bean id="userSessionFactory" class="org.springframework.orm.hibernate3.LocalSessionFactoryBean"> <property name="dataSource" ref="userDataSource"/> etc </bean>
这里没有什么特别的。为了能够使用多个数据库分区,简单地在应用服务器上配置连接池,把数据源加入到spring中,然后添加引用到PartitionRoutingDataSource中。完成了!最美好的事情是你可以增加任意的数据库分区而不需要创建数目众多的session factory。
还有一些其他的事情需要我们去担忧。当hibernate获取到一个连接的时候,你想确认在你开启一个spring事务之前你已经设置好了分区id。换句话说,你需要确认 DatasourceSwitchingAspect 中的order属性值比事务拦截器中的低。这里,DatasourceSwitchingAspect的order设置为1,事务拦截器的order属性设置为2
<aop:config> <aop:pointcut id="profileServicePointcut" expression="execution(* *..ProfileService.*(..))"/> <aop:advisor advice-ref="userTxAdvice" pointcut-ref="profileServicePointcut" order="2"/> </aop:config>
userTxAdvice是我们以前在spring使用的事务通知(advice)。事务管理器是一个普通的HibernateTransactionManager。
Mark Fisher's的博客中其中一条评论中指出,配置的顺序可能会引起hibernate二级缓存的混乱,除非id分开存储。我们考虑了几种办法来解决它,如分派一个范围给每个数据库。但是DBA们比较倾向于一个high-low的表存在GLUD中,因此,我们考虑去实现它。Hibernate有一个high-low的主键生成策略,但是它会认为你插入数据的表在同一个数据库中,但是我们的主键都存在GLUD数据库中。为了不编写我们自己的high-low主键生成策略,我们需要编写一个hibernate主键生成器的包装类。该包装类简单的钩取(grabs)GLUD的session factory来发送主键生成器。GLUD session来自于ApplicationContextAware单例对象,它维持了spring应用上下文的引用,然后在必要的时候钩取GLUD session。由于hibernate在一个我们所不知道的地方创建了主键生成器,因此gludSessionFactory不能通过spring进行依赖注入。
public class UserDbIdGenerator implements IdentifierGenerator, Configurable
{
private MultipleHiLoPerTableGenerator generator;
public ProfileIdGenerator()
{
generator = new MultipleHiLoPerTableGenerator();
}
public Serializable generate(SessionImplementor profileSession, Object entity) throws HibernateException
{
SessionFactory gludSessionFactory = getGludSessionFactory();
Session gludSession = gludSessionFactory.openSession();
Transaction txn = gludSession.beginTransaction();
// Pass through to the wrapped id generator
Long key = (Long) generator.generate((SessionImplementor) gludSession, entity);
txn.commit();
gludSession.close();
return key;
}
protected SessionFactory getGludSessionFactory()
{
SessionFactory sessionFactory =
SpringContextSingleton.getInstance().getBean("gludSessionFactory");
return sessionFactory;
}
public void configure(Type type, Properties props, Dialect dialect) throws MappingException
{
generator.configure(type, props, dialect);
}
}
在我们的hibernate映射文件中,对象应该使用这个主键生成器类:
<class name="Foo" table="foo"> <id name="id" column="id"> <generator class="UserDbIdGenerator"> <param name="primary_key_value">foo</param> <param name="max_lo">5000</param> </generator> </id> </class>
问题
总体来说,这个分区模型运行得很不错。它运行在生产环境中并且性能表现良好。然而如果你要将分区应用到你的应用程序中,我对你有一些建议。
二级缓存
我们的hibernate二级缓存存在相当数量的小故障。简言之,一个hibernate session factory(我们绝对相信它是连到一个数据库),它与多个数据库一起工作就会充满了危险。对于对象缓存,一般没有问题,因为我们的id是唯一的,并且FOO#1只能在一个分区上被发现。然而,查询缓存是一个噩梦。
比方说,你发出查询:“给我从上周开始所有的blog实体”,首先,查询在分区1上面运行,结果他们被缓存在查询缓存。接下来拦截器尝试在分区2上面运行查询,但是由于之前有缓存,因此之前的缓存结果被返回。有些对象不在分区2中,但是它们在缓存中,因此,你会受到欺骗:你的所有返回的对象都来自于分区1,而没有任何对象来自于之后的分区。
一般说来,你能操作属于分区N的 session,但是使用存储在分区M上的对象(因为你在二级缓存中找到了它们),如果你要去数据库中更新这些对象,那么你可能会出错,因为你找到了错误的数据库。
如果你采用shards的方式,使用单一session factory,每个数据库一个二级缓存,那么这些问题将不复存在。
JTA
分区用户数据库和GLUD数据库的系统是一个单元:你不希望事务在一个数据库中提交了,但在另一个数据库中没能提交。如果你想把它们包装在一个事务中,你可能需要使用JTA。我不确信JTA在这种情况下可以运行良好。想象这样的情景:你开启了一个JTA事务,然后使用hibernate session facory对数据库分区1进行操作。如果你进行了更新,hibernate维持该sql直到事务完成。现在,在同一个JTA事务,你连接到分区2.,我们可以看到2个不好的事情发生:1)hibernate说:在这个会话中,我已经有一个分区1的连接了。2)当事务提交时,这个session维持了两个分区的sql,它知道发送到哪个分区吗?
Hibernate的官方文档中有只言片语引导我们:可以配置hibernate会一个一个地发出sql并且释放连接,不过我没有查证这个。我曾经试图在我的应用程序中创建一个JTA事务管理器,但是不能让它运行(spring的JtaTransactionManager拒绝去查找Jboss中的事务管理器)。我花了大概一小时,然后我才知道大概问题所在(classpath中重复的jta.jar)。
此外,在一个单session factory per database分区风格中,JTA应该会正常运行。
测试
当涉及到分区的时候,测试变得很痛苦(并且它不仅针对于我们的分区风格)。我们为分区数据库形成了两种测试方案:一种是ROIT(regular old integration tests),测试dao面向单一分区。随后我们有分区集成测试,使用GLUD和分区。你至少应该编写测试类来测试拦截器,路由datasource和所有的xml配置,来确定它们运行恰当。但是你需要创造力来为这些测试类创建应用程序上下文,来避免手动初始化对象或者编写重复的配置文件。使用DBUnits是一个小小的挑战,因为你通常需要插入或更新GLUD和分区中的数据(或者更多)。
共享对象
最后,更痛苦的一点是,对象在多个分区之间共享是一团糟。假设我们的Blog对象有一个Category属性,它是多对多关系,如果你希望Category对象和Blog在同一数据库中,它们需要存在每一个分区数据库中,因此,category(id=1,name=’java’)将出现在两个分区数据库中,当它们被加载到二级缓存中,它们将出现冲突,你可以关闭缓存来避免这种情况,关闭乐观锁,将它们放到另外的数据库中(GLUD?),不过,如果你有多个session factory(并且二级缓存是分开的)这样的情况不会如此糟糕。
概述
我希望你发现这篇有趣的描述。如果你即将尝试进行分区,你可以考虑使用上文的方法。但是一定要知道以上的问题:二级缓存(可能还有JTA)将不会很好的工作,每个数据库对应一个session factory的策略将消耗更多的资源(并且将降低应用程序的启动速度),但或许可以解决这两个问题。
我想如果我重新开始,我愿意回答多session factory 的方式,或者我再看看hibernate shards怎么样了,那或许要更好。我想当上面的情况改变时我可能会改变我之前的实现方法。不过,通过这些技术实现分区是一件多有趣和有挑战的事情啊。希望大家分享你自己的经验。