简介

MySQLMaxValueIncrementer.java是一个位于org.springframework.jdbc.datasource.support.incrementer包下的一个基于MySQL数据源的自增发号器类,它利用MySQL的last_insert_id()函数和内存缓存巧妙的实现了支持分布式和高效的发号器功能。

继承结构

mysql发号器实现 mysql 发号器_Java

源码阅读

早期版本(spring 4.3.6以前)

先看下早期版本,代码非常简单:

DataFieldMaxValueIncrementer

首先继承树的根节点是DataFieldMaxValueIncrementer接口,他定义了三个方法nextIntValue(), nextLongValue(), nextStringValue(), 就是以不同类型获取发号器的下一个值。

AbstractDataFieldMaxValueIncrementer

AbstractDataFieldMaxValueIncrementer实现DataFieldMaxValueIncrementer接口,是一个抽象类, 它定义了三个属性:

private DataSource dataSource;  // 数据源
private String incrementerName;  // 发号表表名
protected int paddingLength = 0;  // 返回字符串类型补零位数

然后定义了一个抽象方法getNextKey()

protected abstract long getNextKey();

将接口中的三个方法归一化成这一个方法,子类只需实现返回long类型返回值即可。

AbstractColumnMaxValueIncrementer

此类继承AbstractDataFieldMaxValueIncrementer,还是个抽象类,定义了两个属性:

private String columnName;  // 发号序列的列名
private int cacheSize = 1;  // 内存缓存的大小
MySQLMaxValueIncrementer

实现非常简单,其核心就是两句SQL或者说就是last_insert_id()函数的使用,理解了这两句SQL就理解了这个类:

update columName set columnName last_insert_id(columnName + getCacheSize());
select last_insert_id();

last_insert_id()函数的详细介绍传送门, 简单来讲就是:

  1. last_insert_id(), 返回本连接(connection)下,上一次成功插入操作的自增列的值,若插入操作为多行插入,则会返回第一行成功插入的自增值;
  2. 如1中所述,此函数是连接维度的,不同的客户端连接,在MYSQL服务器端维护着不同的值。若同一客户端连接断了,再次重建,值也会重置,新建立的连接是不存在“上一次成功插入”的;是连接维度的,不是表维度的,表A表B都有自增列,SELECT last_insert_id()就是上一次成功插入操作的自增值,跟表无关;
  3. last_insert_id(expr),原返expr值,并使得下一次系统自增值产生前调用LAST_INSERT_ID()也返回expr。

首先第一句SQL将数据库中的序列值更新成当前值+缓存大小的值,并将此值赋给last_insert_id(), 然后再将last_insert_id()的值取出到应用程序中,相当于一次性从数据库中取出了缓存大小个序列值。

这个方案分布式多线程会不会有问题?

不会

  1. 因为last_insert_id()是基于connection的,所以分布式式部署环境下,发号不会乱;
  2. 因为getNextKey()方法由synchronized关键字修饰,并且正常的数据库连接池都不会将一个connection在同一时间分配给不同的线程,所以多线程环境下,发号也不会乱;

源码包括注释如下:

/**
 * 使用MySQL表来实现的DataFieldMaxValueIncrementer自增发号器,与自增列(auto-increment)有同样的效果。
 * 注意: 如果你使用这个类, 你的MySQL表的主键可以不是自增的,由这个类来做这件事。
 *
 * 这个序列在一张表中维护,每个需要额外自增键的表需要这样一个序列表; 序列表的引擎使用MyISAM,在这个引擎下自增序列相关操作不会影响到其他事务(因为MyISAM不支持事务)
 *
 * 举例:
 *
 * create table tab (id int unsigned not null primary key, text varchar(100)); // 主键非自增序列的表
 * create table tab_sequence (value int not null) type=MYISAM;  // 序列表
 * insert into tab_sequence values(0);  // 初始化序列值
 *
 * 如果设置了"cacheSize", 获取中间的值的时候可以避免访问数据库,但如果你的应用停止了或者服务器宕机了又或者事务回滚了,缓存里还没有使用的值就永远都不会被获取到并使用了,所以在序列中出现号码跳跃的最大区间大小为“cacheSize”的值。
 */
public class MySQLMaxValueIncrementer extends AbstractColumnMaxValueIncrementer {

	/** 获取新序列值的SQL */
	private static final String VALUE_SQL = "select last_insert_id()";
	/** 新的序列值 */
	private long nextId = 0;
	/** 当前内存缓存里可发序列的最大值 */
	private long maxId = 0;

	@Override
	protected synchronized long getNextKey() throws DataAccessException {
		if (this.maxId == this.nextId) {
			/*
			* 需要直接使用JDBC风格的代码,因为我们需要确保insert操作和select操作
			* 是在一个connection下进行的 (不然的话我们无法保证last_insert_id()返回正确的值)
			*/
			Connection con = DataSourceUtils.getConnection(getDataSource());
			Statement stmt = null;
			try {
				stmt = con.createStatement();
				DataSourceUtils.applyTransactionTimeout(stmt, getDataSource());
				// 获取序列列名
				String columnName = getColumnName();
                // 更新库中的值为当前值+缓存大小,并将此值设置到last_insert_id()上
				stmt.executeUpdate("update "+ getIncrementerName() + " set " + columnName +
						" = last_insert_id(" + columnName + " + " + getCacheSize() + ")");
				// 查询新的最大值(last_insert_id())
				ResultSet rs = stmt.executeQuery(VALUE_SQL);
				try {
					if (!rs.next()) {
						throw new DataAccessResourceFailureException("last_insert_id() failed after executing an update");
					}
                    // 获取新的缓存最大值
					this.maxId = rs.getLong(1);
				} finally {
					JdbcUtils.closeResultSet(rs);
				}
                // 获取新发号值
				this.nextId = this.maxId - getCacheSize() + 1;
			} catch (SQLException ex) {
				throw new DataAccessResourceFailureException("Could not obtain last_insert_id()", ex);
			} finally {
				JdbcUtils.closeStatement(stmt);
				DataSourceUtils.releaseConnection(con, getDataSource());
			}
		} else {
			this.nextId++;
		}
		return this.nextId;
	}

}

优化版本

可以在上边源码注释中注意到,作者建议使用此类时,应该将表的引擎设置为MyISAM,这显然产生了局限性,于是,在spring4.3.6以后,作者优化了代码,支持InnoDB引擎。

作者在github上的commit原话:

我们不必依赖MYISAM来构建序列表了,因为有些场景下MYISAM并不总是可用的。在这次更改后,序列表可以使用INNODB和MYISAM,序列操作分配给一个新的连接,避免受到其他可能存在的事务的影响。
为了支持用户继续支持老的使用MYISAM表的序列表, 我们添加了useNewConnection标识去控制是否使用一个新的连接,默认是true。

源码包括注释如下:

/**
 * 使用MySQL表来实现的DataFieldMaxValueIncrementer自增发号器,与自增列(auto-increment)有同样的效果。
 * 注意: 如果你使用这个类, 你的MySQL表的主键可以不是自增的,由这个类来做这件事。
 *
 * 这个序列在一张表中维护,每个需要额外自增键的表需要这样一个序列表。表的存储引擎可以是MYISAM或INNODB,因为这个对这个序列的操作使用一个独立的连接,故不会受到其它事务的影响.
 *
 * 举例:
 *
 * create table tab (id int unsigned not null primary key, text varchar(100));
 * create table tab_sequence (value int not null);
 * insert into tab_sequence values(0);
 *
 * 如果设置了"cacheSize", 获取中间的值的时候可以避免访问数据库,但如果你的应用停止了或者服务器宕机了又或者事务回滚了,缓存里还没有使用的值就永远都不会被获取到并使用了,所以在序列中出现号码跳跃的最大区间大小为“cacheSize”的值。
 *
 * 可以通过设置“useNewConnection”的值为false来避免发号操作使用一个新的连接。但是这种情况下你得使用一个无事务的存储引擎,比如MYISAM。
 */
public class MySQLMaxValueIncrementer extends AbstractColumnMaxValueIncrementer {

	/** 获取新序列值的SQL */
    private static final String VALUE_SQL = "select last_insert_id()";
    /** 新的序列值 */
    private long nextId = 0;
    /** 当前内存缓存里可发序列的最大值 */
    private long maxId = 0;
	/** 本发号器每次发号是否使用一个新的数据库连接 */
	private boolean useNewConnection = true;

	/**
	 * 设置本发号器是否每次发号是否使用一个新的数据库连接
	 * 发号表使用支持事务的存储引擎则必须设置为true, 自增操作使用一个独立的事务;
	 * 发号表使用无事务的存储引擎(如MYISAM)设置为false就足够了,省去为自增发号操作获取一个新的连接
	 * 从Spring Framework 5.0.开始默认为false
	 * @since 4.3.6
	 */
	public void setUseNewConnection(boolean useNewConnection) {
		this.useNewConnection = useNewConnection;
	}

	@Override
	protected synchronized long getNextKey() throws DataAccessException {
		if (this.maxId == this.nextId) {
			/*
			* 如果useNewConnection为true, 获取一个未被管理的连接,让我们的操作在一个独立的事务中
			* 如果useNewConnection为false,使用当前事务的连接, 但依赖发号表是无事务的表.
            * 需要直接使用JDBC风格的代码,因为我们需要确保insert操作和select操作是在一个connection下进行的 (不然的话我们无法保证last_insert_id()返回正确的值)
			*/
			Connection con = null;
			Statement stmt = null;
			boolean mustRestoreAutoCommit = false;
			try {
				if (this.useNewConnection) {
                    // 若使用新连接,直接从数据源获取连接
					con = getDataSource().getConnection();
					if (con.getAutoCommit()) {
                        // 连接是自动提交的,则先设置为非自动提交
						mustRestoreAutoCommit = true;
						con.setAutoCommit(false);
					}
				} else {
					// 不使用新连接,尝试获取当前线程的连接
					con = DataSourceUtils.getConnection(getDataSource());
				}
				stmt = con.createStatement();
				if (!this.useNewConnection) {
                    // 设置事务语句超时时间
					DataSourceUtils.applyTransactionTimeout(stmt, getDataSource());
				}
				String columnName = getColumnName();
				try {
					// 更新库中的值为当前值+缓存大小,并将此值设置到last_insert_id()上
					stmt.executeUpdate("update " + getIncrementerName() + " set " + columnName +
							" = last_insert_id(" + columnName + " + " + getCacheSize() + ")");
				} catch (SQLException ex) {
					throw new DataAccessResourceFailureException("Could not increment " + columnName + " for " +
							getIncrementerName() + " sequence table", ex);
				}
				// 查询新的最大值(last_insert_id())
				ResultSet rs = stmt.executeQuery(VALUE_SQL);
				try {
					if (!rs.next()) {
						throw new DataAccessResourceFailureException("last_insert_id() failed after executing an update");
					}
					// 获取新的缓存最大值
					this.maxId = rs.getLong(1);
				} finally {
					JdbcUtils.closeResultSet(rs);
				}
				this.nextId = this.maxId - getCacheSize() + 1;
			} catch (SQLException ex) {
				throw new DataAccessResourceFailureException("Could not obtain last_insert_id()", ex);
			} finally {
				JdbcUtils.closeStatement(stmt);
				if (con != null) {
					if (this.useNewConnection) {
						try {
                            // 提交事务
							con.commit();
							if (mustRestoreAutoCommit) {
								// 如果需要,恢复连接原设置
                                con.setAutoCommit(true);
							}
						} catch (SQLException ignore) {
							throw new DataAccessResourceFailureException(
									"Unable to commit new sequence value changes for " + getIncrementerName());
						}
						JdbcUtils.closeConnection(con);
					} else {
						DataSourceUtils.releaseConnection(con, getDataSource());
					}
				}
			}
		} else {
			this.nextId++;
		}
		return this.nextId;
	}

}

个人理解

读过源码之后,期初我有一些疑问点,这里也写出来分享下,如果有理解错误也请路过大佬指出:

  1. Spring中同一个连接(connection)会不会在同一个时间点被不同的线程所持有?
    答:可以,但是正常的连接池不会这么设计。如果同一个连接在同一时间点能够被多个线程持有,那么优化前以及优化后使用MYISAM的本类就废了,没有事务控制update和select两步操作不具有原子性,就会出现并发问题。博主看了下DBCP连接池的源码,连接被分配出去以后再归还入池之前是不会分配给另一个获取连接的请求的。一开始博主的脑子有点懵,去stackoverflow上问了这个问题,遇到一个虽然提供了有效资料无论如何也get不到我的问题点的老外,不知道是不是因为我的英语太差劲了……
  2. 优化后使用InnoDB引擎时,作者提到的获取独立连接,免受其他事务影响是什么意思?
    答:若自增操作getNextKey()调用前,本线程的连接就开启了一个事务,那么这个事务若回滚,数据库层面回滚了,业务数据(缓存最大值、当前值)却没有回滚,就会出现重复发号的问题。
  3. 获取了独立的连接,为什么还要使用事务?
    答:让update和select两步操作有原子性,防止select时异常回滚,导致本次update出的号段不可用,出现号码跳跃。

多行发号器扩展

可以看到,现有的代码要求对每一个自增序列新建一张表(其实也可以一张表多列,每一列是一个序列),很多时候我们不希望频繁的建表,所以依据已有的思想,可以自行实现一个支持多行的发号器类,定义一个指定行的属性,update语句加个where条件即可。后边后空在此补一个示例。