最近项目中遇到集群问题,比如我们有两个集群节点,在正常情况下只有一个节点工作(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