Sequence Generator

Posted by 咖啡不苦 on 2018-08-06

Sequence(唯一id) 生成器

在如今的分布式场景下,MySql分库分表非常常见。我们不能用MySql的auto increment字段,这是单表的。我们需要保证一个逻辑表的若干物理表里的数据的主键互相不重复。因为如果存在重复的情况,当物理表扩容或缩容导致数据移动并进一步导致两条拥有相同ID的数据进入到一张表里时,就会造成主键冲突。所以,我们需要一个全局的,高性能的唯一主键生成器。

GUID 或UUID 貌似就符合这个要求!但是,一来这类id不是数字的,生成不具有全局顺序性。二来,也是因为不是数字的,做MySql的主键,性能不如数字。所以,虽然生成的成本很低,也要放弃这个方案。

我们的方案是,在数据库中持久化一个数值,表示当前ID增长到了哪,要获取下一个ID时则带条件的去更新这个值为当前值+1(乐观锁),如果更新成功,则认为获取到了新的ID,如果不成功,则再试到成功为止。进一步的,为了在逻辑表和应用服务器都比较多的情况下,降低持有这个值的表过热,乐观锁碰撞过多的情况。允许每个应用服务器持有一个容量为n的ID块,然后n次内的ID获取在应用服务器内同步的完成,第n次获取,才向数据库发一次update操作,修改数据库从+1变成+n。大概示意图如下:

下面来详细看下实现

首先,由于上面提到的应用服务器内持有一个容量为n的id块,我们设计了一个内部类Step来实现这个功能。

static class Step {
        private long currentValue; // 当前值,调用incrementAndGet()返回下一个id
        private long endValue;  // 当前块的结束值,限定Step的容量

    	// 初始化时  endValue - currentValue = n   (blockSize)
        Step(long currentValue, long endValue) {
            this.currentValue = currentValue;
            this.endValue = endValue;
        }

        public void setCurrentValue(long currentValue) {
            this.currentValue = currentValue;
        }

        public void setEndValue(long endValue) {
            this.endValue = endValue;
        }
      
        // 因为这个方法 外面是在同步块里调用的,所以这里没用synchronize,
        // currentValue 也没用 AtomicLong
        public  long incrementAndGet() {
            return ++currentValue;
        }
    }

我们再看Sequence类:

public class Sequence {
    private int blockSize = 5; // 容量或者叫步长,一次从数据库中获取多少个ID
    private long startValue = 0; // id从几开始涨,在已有数据要迁移的情况下,可以设置为非零的数
    // 每个表一个Step,这个map持有了所有表的Step引用
    // 外面是同步的  所以也不用concurrentHashMap
    private Map<String,Step> stepMap = new HashMap<String, Step>();
    
    // 持有数据源和三条sql。用于持久化的表名是写死的:sequence_value
    // 数据源通过spring注入
    private DataSource dataSource;
    private final static String GET_SQL = "select id from sequence_value where name = ?";
    private final static String NEW_SQL = "insert into sequence_value (id,name) values (?,?)";
    // 这条sql里where条件里的id=?很重要,是一个乐观锁机制,防止过程中已经有别的进程(别的Sequence实例)
    // 对表进行了更新,导致id重复
    private final static String UPDATE_SQL = "update sequence_value set id = ?  where name = ? and id = ?";
    // ...
}

Sequence类的唯一入口方法 get的实现:

// 这个方法必须是同步的,防止多个线程同时获取同一表的id,导致重复
public synchronized long get(String sequenceName) {
        Step step = stepMap.get(sequenceName);
    	if(step == null) {
            // step == null 表示第一次获取这个表的id,后面的逻辑会继续走,从数据库中读入
            // 直接new一个Step放到map里备用,下次就能走else里的逻辑了
            step = new Step(startValue,startValue+blockSize);
            stepMap.put(sequenceName, step);
        } else {
            // 当前块还没用完,直接内存里返回下一个ID就好
            // 否则的话,表示当前块里id用完了,继续走下面的从数据库中获取的逻辑
            if (step.currentValue < step.endValue) {//默认为0和0,所以没有错
                return step.incrementAndGet();
            }
        }
    
        // 尝试blockSize次 从数据库获取下一个块
        for (int i = 0; i < blockSize; i++) {
            if (getNextBlock(sequenceName,step)) {
                //  一旦获取到,就直接内存里返回,不用再getNextBlock了
                return step.incrementAndGet();
            }
        }
    
        // 尝试了若干次都失败了,只能抛异常出去
        throw new RuntimeException("No more value.");
    }

我们再看看从数据库中获取下一个块的实现是怎么样的。其实从上面看到的3条sql基本上就已经了解得7788了。

private boolean getNextBlock(String sequenceName, Step step) {
    	// 发select语句,获取库里当前值
        Long value = getPersistenceValue(sequenceName);
        if (value == null) {
            try {
                // 如果没有,就初始化, 发insert语句,
                // insert的值是上面配置的 startValue, 默认为0, 返回的是刚插入的值
                value = newPersistenceValue(sequenceName); 
            } catch (Exception e) {
                // 如果初始化失败,说明有程序先初始化了,(别的进程也同时对这个表进行id获取)
                // 这里需要对sequence_value表的name字段做唯一索引限制。否则会有问题
                // 可能两个进程同时插入了两条name相同的数据
                log.error("newPersistenceValue error!");
                value = getPersistenceValue(sequenceName); 
            }
        }
        // 发update语句,申请下一个块,将库里的值update为 value+blockSize ,
        // 这样,这个进程里的请求就能直接内存返回
        // 而别的进程 去getNextBlock时,会改成更大的值,
        // 比如A进程持有的是 0-5, B进程持有的是 6-10
     	// 这条update语句是带条件更新的,前面已经有提到,也是为了防止并发产生id重复
        // 因为带条件更新可能失败, 所以 如果失败,外面的get方法要进行重试。 策略是重试blockSize次
        boolean b = saveValue(value,sequenceName) == 1;
        if (b) {
            // 成功获取到下一个块了,更新step对象,后面就可以用step.incrementAndGet()了。
            step.setCurrentValue(value);
            step.setEndValue(value+blockSize);
        }
        return b;
    }

到此,Sequence的核心实现就完成了。getPersistenceValue newPersistenceValue saveValue 这三个jdbc操作数据库的方法没什么特别的,就不贴出来细看了。

有些时候,除了我们的主业务表比较大,id需求比较大,需要单独一个Sequence名字,其他很多小表可以共用一个默认的Sequence名字,所以,就又提供了一个SequenceUtil类。作为Sequence的一个代理,如果没有对应名字的id序列,就用默认的序列。

public class SequenceUtil {
	public long get(String name) {
        Sequence sequence = null;
        if (sequenceMap != null) {
            sequence = sequenceMap.get(name);
        }
        if (sequence == null) {
            // 如果从持有的map里没取到这个名字的Sequence的话,就取默认的
            if (defaultSequence != null) {
                return defaultSequence.get(name);
            } else {
                // 默认的还没有配置,就抛异常
                throw new RuntimeException("sequence " + name + " undefined!");
            }
        }
        // 代理到具体的Sequence实例 进行id的get
        return sequence.get(name);
    }
}

OK,到此为止这个精巧的设计就介绍完了。如果要生成id的表太多,导致Sequence_value表过热,可以把BlockSize调大一些,减少对库的操作次数。甚至可以设置几个不同的实例,连接不同的数据源。 这个设计有一个需要注意的地方,就是生成的id是大体上全局有序的,但不是严格有序的。比如A实例持有该1-5,B实例持有6-10。生成的id可能是1,6,2,7……

都看到这了,说明是真爱!那就给个赠品吧。flickr的Ticket Server设计。这我们这个设计有许多相似之处。供参考:http://code.flickr.net/2010/02/08/ticket-servers-distributed-unique-primary-keys-on-the-cheap/