需求
所有业务共用一个序列 ID,要求 ID 必须从0开始,要求 ID 必须自增,要求 ID 能够归零。所以类似 UUID 或者 snowflake 这种全局 ID 生成的方案就不在考虑了,必须依靠数据存储完成。
数据库自增 ID
利用数据库的自增键,所有需要申请 ID 的业务,统一通过数据库事务进行锁定并获取值。
create table counter (
`id` INT UNSIGNED AUTO_INCREMENT,
`stub` char(1),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
配置 mybatis 获取自增 id
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="me.xiang.demo.idgenerator.mapper.IDMapper">
<cache/>
<sql id="TABLE_NAME">counter</sql>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into
<include refid="TABLE_NAME"/>
(stub) values (#{stub})
</insert>
</mapper>
用 mybatis-spring-boot-starter 来帮助配置 datsource、sqlSessionFactory 以及 mapper scan。
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/local?user=xiang&password=123456
mybatis.mapperLocations=classpath:me.xiang.demo.idgenerator.mapper/*.xml
创建自增 ID 服务。
@Component
public class MybatisIDService implements IDService{
@Autowired
IDMapper idMapper;
public long getNextID() {
ID stub = new ID('a');
idMapper.insert(stub);
return stub.getId();
}
}
redis 自增 ID
使用 Redis 提供的原子 INCR 操作,维护一个计数器,并以计数器的值作为 ID。
使用 spring-data-redis 配置一个 RedisAtomLong 对象,原子性更新并获取值。
# Redis数据库索引(默认为0)
spring.redis.database=0
# Redis服务器地址
spring.redis.host=127.0.0.1
# Redis服务器连接端口
spring.redis.port=6379
# Redis服务器连接密码(默认为空)
spring.redis.password=
由于 RedisAtomLong 要求必须以 RedisTemplate<String, Long>作为参数,所以需要手动构建此 bean,spring-data-redis 已经提供了 RedisConnectionFactory。
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Long> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate <String, Long> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new Jackson2JsonRedisSerializer<Long>(Long.class));
return template;
}
}
创建 ID 自增服务
@Component
public class RedisIDService implements IDService{
RedisAtomicLong atomicLong;
@Autowired
public RedisIDService(RedisTemplate<String, Long> idTemplate){
this.atomicLong = new RedisAtomicLong("counter", idTemplate);
}
public long getNextID(){
return atomicLong.incrementAndGet();
}
}
分段式自增 ID
因为无论是 MySQL 还是 Redis,都涉及到竞争的问题。Redis 的原子变量只是把多并发的获取变成了单线程的操作。MySQL 则是数据库对于不同线程进来的事务进行锁竞争(这里也比较复杂,涉及到 InnoDB 对于不同自增模式下,锁的实现不同,但仍然有锁)。如果说 IO 不是关键压力的话,那么还有可能发生在竞争上面。中心锁的一种常见解决方案就是分段发放,即一次发放一批,省去频繁申请的过程,这样带来的结果就是 ID 可能出现空洞,即发了没用完又来申请的情况,要看业务是否可以容忍。
按照这个思路,后端是数据库还是 Redis 都可以。主要是客户端需要维护一个 ID 段进行发放,发放完成后再次进行申请。数据库的话,需要维护一条记录已发放的 ID 即可,多客户端竞争依靠数据库的行级锁。Redis 使用一个锁和一个计数器进行配合。客户端每次更新计数器前申请锁,更新后释放。或者省事一点,spring-data-redis 的 RedisAtomLong 也同样支持原子更新。
客户端维护 ID 段又涉及到了本地的多线程竞争,原子计数器的更新不是问题,问题在于判断是否要申请新的 ID 段。这可能导致分配方法本身要做成一个同步方法,更好的办法就是用 CAS 来解决这个问题,不要依赖于锁的阻塞。
使用 Redis 的客户端 ID 分段方案。
package me.xiang.demo.idgenerator.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.support.atomic.RedisAtomicLong;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicLong;
@Component
public class RedisSegmentIDService implements IDService {
RedisAtomicLong atomicLong;
AtomicLong currentId = new AtomicLong(-1L);
AtomicLong start = new AtomicLong(-1L);
AtomicLong end = new AtomicLong(-1L);
@Autowired
public RedisSegmentIDService(RedisTemplate<String, Long> idTemplate){
this.atomicLong = new RedisAtomicLong("counter", idTemplate, 0L);
}
public long getNextID(){
if (currentId.get() > end.get()){
getNextSegment();
}
long tempId = currentId.get();
while (!currentId.compareAndSet(tempId, tempId+1)) {
tempId = currentId.get();
}
return tempId+1;
}
private void getNextSegment2(){
long tempEnd = end.get();
//同时进入 if 条件可能造成 redis 申请了一批但是由于 CAS 放弃了。可以考虑将方法加锁,毕竟申请操作频度低很多,while 循环还需要浪费判断条件。
while (currentId.get() > end.get()) {
if (end.compareAndSet(tempEnd, atomicLong.addAndGet(1000))) {
start = new AtomicLong(end.get() - 1000);
}
}
}
private synchronized void getNextSegment(){
if (currentId.get() > end.get()) {
end.set(atomicLong.addAndGet(1000));
start = new AtomicLong(end.get() - 1000);
}
}
public void print(){
System.out.println("CurrentID is: " + currentId.get());
System.out.println("start is: " + start.get());
System.out.println("end is: " + end.get());
}
}
性能对比
创建一批线程,线程内部限时1分钟,循环申请ID,看看单位时间内的 ID 发放数量对比。
public class IDTask implements Runnable{
private IDService idService;
private int counter;
private Long start = 0L;
public IDTask(IDService idService) {
this.idService = idService;
this.counter = 0;
}
public void run() {
this.start = System.currentTimeMillis();
String threadName = Thread.currentThread().getName();
while (true) {
try {
Thread.sleep(1L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
this.idService.getNextID();
counter += 1;
if (timeout()) {
IDTaskManager.addCounter(threadName, counter);
return;
}
}
}
public boolean timeout() {
return (System.currentTimeMillis() - start) > 60*1000L;
}
}
@Component
public class IDTaskManager {
private static int counter = 0;
@Autowired
private RedisSegmentIDService idService;
public static synchronized void addCounter(String threaName, int value) {
counter += value;
System.out.println("Updated Counter: " + counter);
}
public void start(int threadCount) throws InterruptedException{
counter = 0;
System.out.println("START THREAD COUNT: " + threadCount);
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("id-task-thread-%d").build();
ExecutorService executor = Executors.newFixedThreadPool(threadCount, namedThreadFactory);
int i = 0;
while (i < threadCount) {
IDTask idTask = new IDTask(idService);
executor.submit(idTask);
i++;
}
}
}
mysql (Hikari 连接池):
100 线程: Updated Counter: 150366
200 线程: Updated Counter: 159485
300 线程: Updated Counter: 150012
redis:
100 线程:Updated Counter: 1399911
200 线程:Updated Counter: 1412502
300 线程:Updated Counter: 1459106
redis 分段:
100 线程:Updated Counter: 4822451 200 线程:Updated Counter: 7904208 300 线程:Updated Counter: 6775957(竞争太激烈导致发放降低,一般来说一个 JVM 不会同时300个线程抢占的情况)
总结
分段的性能还是明显要好不少的,只要能接受由于程序重启等原因出现 ID 空洞的情况。Redis 的情况相对来说更简洁直观,性能折中。MySQL 适用于不想让应用架构里多出个 Redis,同时对性能要求也不高的情况。