最近项目中遇到集群问题,比如我们有两个集群节点,在正常情况下只有一个节点工作(A),当出现异常时切换到另一个集群节点(B)上。项目中使用Hibernate的increment作为数据库主键生成策略。它的原理如下:
Hibernate初始化完成后,当获取主键时,会查询一次数据库将最大的Id查询出来,之后的操作就全部是在内存中维护主键的自增,保存时更新到数据库,其源码如下:
package org.hibernate.id;
import java.io.Serializable;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Properties;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.hibernate.HibernateException;
import org.hibernate.MappingException;
import org.hibernate.dialect.Dialect;
import org.hibernate.engine.SessionFactoryImplementor;
import org.hibernate.engine.SessionImplementor;
import org.hibernate.exception.JDBCExceptionHelper;
import org.hibernate.jdbc.Batcher;
import org.hibernate.mapping.Table;
import org.hibernate.type.Type;
import org.hibernate.util.StringHelper;
public class IncrementGenerator
implements IdentifierGenerator, Configurable
{
private static final Log log = LogFactory.getLog(IncrementGenerator.class);
private long next;
private String sql;
private Class returnClass;
public synchronized Serializable generate(SessionImplementor session, Object object)
throws HibernateException
{
if (this.sql != null) {
getNext(session);
}
return IdentifierGeneratorFactory.createNumber(this.next++, this.returnClass);
}
public void configure(Type type, Properties params, Dialect dialect)
throws MappingException
{
String tableList = params.getProperty("tables");
if (tableList == null) tableList = params.getProperty("identity_tables");
String[] tables = StringHelper.split(", ", tableList);
String column = params.getProperty("column");
if (column == null) column = params.getProperty("target_column");
String schema = params.getProperty("schema");
String catalog = params.getProperty("catalog");
this.returnClass = type.getReturnedClass();
StringBuffer buf = new StringBuffer();
for (int i = 0; i < tables.length; ++i) {
if (tables.length > 1) {
buf.append("select ").append(column).append(" from ");
}
buf.append(Table.qualify(catalog, schema, tables[i]));
if (i >= tables.length - 1) continue; buf.append(" union ");
}
if (tables.length > 1) {
buf.insert(0, "( ").append(" ) ids_");
column = "ids_." + column;
}
this.sql = "select max(" + column + ") from " + buf.toString();
}
private void getNext(SessionImplementor session)
{
log.debug("fetching initial value: " + this.sql);
try
{
PreparedStatement st = session.getBatcher().prepareSelectStatement(this.sql);
try {
ResultSet rs = st.executeQuery();
try {
if (rs.next()) {
this.next = (rs.getLong(1) + 1L);
if (rs.wasNull()) this.next = 1L;
}
else {
this.next = 1L;
}
this.sql = null;
log.debug("first free id: " + this.next);
}
finally {
rs.close();
}
}
finally {
session.getBatcher().closeStatement(st);
}
}
catch (SQLException sqle)
{
throw JDBCExceptionHelper.convert(session.getFactory().getSQLExceptionConverter(), sqle, "could not fetch initial value for increment generator", this.sql);
}
}
}
/* Location: D:\Workspace\HibernateTest\bin\lib\hibernate3.jar
* Qualified Name: org.hibernate.id.IncrementGenerator
* Java Class Version: 1.4 (48.0)
* JD-Core Version: 0.5.3
*/
大家请看其generate方法,当sql语句不等于Null的时候,获取下一个版本,也就是从数据库拿最大的id,然后就将sql置为null。这样下次获取id时,就不会从数据库拿,而是在内存++。那么这样就会产生问题。比如系统现在在A节点上工作,当next走到10的时候,突然网络断了,于是系统切换到了B节点上工作,B节点获取id时查询数据库拿到最大值,并且顺利执行next走到了20,此时又切回到了A节点,然而A节点的next此时为10,并且sql语句为null,于是A不查询数据库直接取next的值,那么将导致从10开始到20的id都会发生主键冲突,必须重启A节点才能解决问题。
因此如果涉及到使用hibernate的集群一定不能使用increment做为主键生成策略。从上面分析我们可以看出如果想解决该问题,必须解决两个进程之间的内存共享,也就是共享next变量,但是实现起来很复杂。最后采用Hilo的主键生成策略解决。其部分源码如下:
public Serializable doWorkInCurrentTransaction(Connection conn, String sql)
throws SQLException
{
int result;
int rows;
do
{
sql = this.query;
SQL.debug(this.query);
PreparedStatement qps = conn.prepareStatement(this.query);
try {
ResultSet rs = qps.executeQuery();
if (!(rs.next())) {
String err = "could not read a hi value - you need to populate the table: " + this.tableName;
log.error(err);
throw new IdentifierGenerationException(err);
}
int result = rs.getInt(1);
rs.close();
}
catch (SQLException sqle)
{
throw sqle;
}
finally {
qps.close(); }
sql = this.update;
SQL.debug(this.update);
PreparedStatement ups = conn.prepareStatement(this.update);
int rows;
try {
ups.setInt(1, result + 1);
ups.setInt(2, result);
rows = ups.executeUpdate();
}
catch (SQLException sqle)
{
throw sqle;
}
finally {
ups.close();
}
}
while (rows == 0);
return new Integer(result);
}
}
上面代码说明获取主键时,hibernate都会在同一个事务中从数据库中拿出主键,并将该主键更新。这样保证另外的进程从数据库取值时能够获取最大值。
关键是下面的代码:
public class TableHiLoGenerator extends TableGenerator
{
public static final String MAX_LO = "max_lo";
private long hi;
private int lo;
private int maxLo;
private Class returnClass;
private static final Log log = LogFactory.getLog(TableHiLoGenerator.class);
public void configure(Type type, Properties params, Dialect d) {
super.configure(type, params, d);
this.maxLo = PropertiesHelper.getInt("max_lo", params, 32767);
this.lo = (this.maxLo + 1);
this.returnClass = type.getReturnedClass();
}
public synchronized Serializable generate(SessionImplementor session, Object obj) throws HibernateException
{
if (this.maxLo < 1)
{
int val = ((Integer)super.generate(session, obj)).intValue();
return IdentifierGeneratorFactory.createNumber(val, this.returnClass);
}
if (this.lo > this.maxLo) {
int hival = ((Integer)super.generate(session, obj)).intValue();
this.lo = ((hival == 0) ? 1 : 0);
this.hi = (hival * (this.maxLo + 1));
log.debug("new hi value: " + hival);
}
return IdentifierGeneratorFactory.createNumber(this.hi + this.lo++, this.returnClass);
}
}
我们可以看到 ,查询主键时将数据库的的主键+1保存,如果这个事物没有成功,那么this.lo将永远大于this.maxLo,即便是此时切换到了B,当再次回到A时,首先会查询一次数据库,这样保证this.hi永远不会相等。如果成功那么当从B切换到A时,由于B的hi值比A的hi值至少大this.maxLo+1,因此,即便A保持hi不变从内从中拿低位,也不会和B相同,因为当this.lo大于this.maxLo时又会查询数据库。这个算法太美了。
其详细原理可以参照源码和下面的链接对比:
http://hi.baidu.com/sai5d/blog/item/88e5f4db09e90277d0164e30.html