在 Java 中利用 redis 实现分布式架构的全局唯一标识服务

获取全局唯一标识的方法介绍

在一个IT系统中,获取一个对象的唯一标识符是一个普遍的需求。在以前的单体应用中,如果数据库是一个单数据库的结构。通常可以利用数据库的自增字段来获取这个唯一标识。例如,在 Mysql 数据库中,我们可以通过 sql 语句创建一个自增长的 int 字段类型的表。如下所示。

CREATE TABLE student
(
    id INT NOT NULL AUTO_INCREMENT,
    name VARCHAR(16),
    PRIMARY KEY (id)
)

然后插入两条数据

INSERT INTO student(name) VALUE('yanggch');
INSERT INTO student(name) VALUE('grace');

通过 SQL 语句查看表数据

 SELECT * FROM student;

得到如下的结果


在 Java 中利用 redis 实现分布式架构的全局唯一标识服务
数据库查询结果

可以看到,虽然我们在通过 SQL 插入数据的时候没有指定 id 字段的值,但是因为该字段的 AUTO_INCREMENT 自增长的特性,自动的给两条记录添加了1和2两个值。

这个方法有两个主要问题。一个是如果是一个分库分表的数据库结构,那么在分布在不同实例中的同一个表中的id是重复的。另一个问题是记录插入到数据库里后,我们在代码中并不能知道刚刚插入数据库的记录的主键的值到底是什么。如果我们的一个业务是要同时插入一条主表记录一节一系列以这条主表记录主键为外键的子表记录,我们在插入子表记录的时候,不知道对应的外键的值是多少。导致无法插入。例如如果我们有一个下单业务,要求在订单表中插入一条订单记录,同时在订单明细中插入多条在这个订单中购买的商品的详细信息的记录。订单数据插入成功后,我们不知道订单的主键的值,所以我们也就无法正确的插入商品详细信息记录了。

另外一个利用数据库自增字段属性获取唯一标识方式是在数据库中建立一个带一个自增字段的数据表。每次在表中插入一条记录,然后将这条记录的值取出来作为主键值。这个的问题是每次要另外在数据表中插入一条记录,同时在多用户使用的环境下,要严格保证你取到的记录就是你插入的记录。否则会导致主键重复。着会让获取唯一标识符的速度变得比较慢。同时,这个方式在分库分表的结构下,也不能让唯一标识在全局唯一。

还有一些其他的方式。例如用 uuid 算法可以保证全局唯一,也能保证高性能。但是他生成是一个字符串,不能保证顺序性,同时也太长了。

所以在分布式架构中,我们就需要一个满足如下条件的唯一标识符服务

  1. 全局唯一
  2. 高性能
  3. 具备顺序性
  4. 可以附加其他业务属性

这里我们可以用 redis 的 INCR 命令来作为生成全局的唯一标识符。INCR 命令的语法是

INCR key

根据 redis 的官网的 INCR 命令介绍,它是一个原子操作,效果是是将 redis 数据库中 key 的值加一并且返回这个结果。如果 key 不存在,将在执行加一操作前,将这个 key 的值设置为0,也就是说执行这个命令的结果是从 1 开始一直累加下去的。

同时我们可以看到这个命令的算法时间复杂度是 O(1),而 redis 的数据是存储在内存中的,这个命令的执行速度是非常快的。在 redis 服务器为双核 16g环境下,通过千兆局域网在另一台服务器上命令行执行压力测试

redis-benchmark -h 10.110.2.56 -p 52981 -a hhSbcpotThgWdnxJNhrzwstSP20DvYOldkjf

结果如下


在 Java 中利用 redis 实现分布式架构的全局唯一标识服务
redis INCR 压力测试结果

可以看到每秒可以生成5万个标识。这个可以满足一般的高性能需求了


通过 Java 和 redis 实现一个全局唯一标识服务

接下来我们来用继续来在 Java 中利用 redis 来实现一个全局唯一标识的服务。这个服务要满足如下的需求

  1. 全局唯一
  2. 高性能
  3. 具备顺序性
  4. 可以将日期数字作为全局唯一标识的前缀
  5. 可以每天从 1 开始重新计数
  6. 不同的实体类型可以单独生成标识。例如订单标识,会员标识
  7. 可以在新的一天中从 1 开始计数

定义唯一标识服务接口

package com.x9710.common.redis;

/**
 * 全局唯一标识服务接口
 *
 * @author 杨高超
 * @since 2017-12-10
 */
public interface UUIDService {

/**
 * 每天从 1 开始生成唯一标识
 *
 * @param key     要生成唯一标识的对象
 * @param length  要生成为唯一标识后缀的长度。不包括需要附加的时间前缀
 *                如果 haveDay = false 或者 length 长度小于标识后缀的长度则无效
 * @param haveDay 是否要附加日期前缀
 * @return 唯一标识
 * @throws Exception 异常
 */
Long fetchDailyUUID(String key, Integer length, Boolean haveDay) throws Exception;

/**
 * 全局从 1 开始生成唯一标识
 *
 * @param key     要生成唯一标识的对象
 * @param length  要生成为唯一标识后缀的长度。不包括需要附加的时间前缀
 *                如果 haveDay = false 或者 length 长度小于标识后缀的长度则无效
 * @param haveDay 是否要附加日期前缀。
 * @return 唯一标识
 * @throws Exception 异常
 */
Long fetchUUID(String key, Integer length, Boolean haveDay) throws Exception;
}

基于 redis 实现唯一标识服务

package com.x9710.common.redis.impl;

import com.x9710.common.redis.RedisConnection;
import com.x9710.common.redis.UUIDService;
import redis.clients.jedis.Jedis;

import java.text.DateFormat;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.GregorianCalendar;

/**
 * @author 杨高超
 * @since 2017-11-19
 */
public class UUIDServiceRedisImpl implements UUIDService {
private RedisConnection redisConnection;
private Integer dbIndex;

private DateFormat df = new SimpleDateFormat("yyyyMMdd");

public void setRedisConnection(RedisConnection redisConnection) {
    this.redisConnection = redisConnection;
}

public void setDbIndex(Integer dbIndex) {
    this.dbIndex = dbIndex;
}

public Long fetchDailyUUID(String key, Integer length, Boolean haveDay) {
    Jedis jedis = null;
    try {
        jedis = redisConnection.getJedis();
        jedis.select(dbIndex);
        Calendar now = new GregorianCalendar();
        String day = df.format(now.getTime());
        //新的一天,通过新 key 获取值,每天都能从1开始获取
        key = key + "_" + day;
        Long num = jedis.incr(key);
        //设置 key 过期时间
        if (num == 1) {
            jedis.expire(key, (24 - now.get(Calendar.HOUR_OF_DAY)) * 3600 + 1800);
        }
        if (haveDay) {
            return createUUID(num, day, length);
        } else {
            return num;
        }
    } finally {
        if (jedis != null) {
            jedis.close();
        }
    }
}

public Long fetchUUID(String key, Integer length, Boolean haveDay) {
    Jedis jedis = null;
    try {
        jedis = redisConnection.getJedis();
        jedis.select(dbIndex);
        Calendar now = new GregorianCalendar();
        Long num = jedis.incr(key);
        
        if (haveDay) {
            String day = df.format(now.getTime());
            return createUUID(num, day, length);
        } else {
            return num;
        }
    } finally {
        if (jedis != null) {
            jedis.close();
        }
    }
}

private Long createUUID(Long num, String day, Integer length) {
    String id = String.valueOf(num);
    if (id.length() < length) {
        NumberFormat nf = NumberFormat.getInstance();
        nf.setGroupingUsed(false);
        nf.setMaximumIntegerDigits(length);
        nf.setMinimumIntegerDigits(length);
        id = nf.format(num);
    }
    return Long.parseLong(day + id);
}
}

编写测试用例

在 Junit4 中不支持多线程测试,所以这里直接采用了 main 方法中运行测试用例。

package com.x9710.common.redis.test;

import com.x9710.common.redis.RedisConnection;
import com.x9710.common.redis.impl.UUIDServiceRedisImpl;

import java.util.Date;

public class RedisUUIDTest {

public static void main(String[] args) {
    for (int i = 0; i < 20; i++) {
        new Thread(new Runnable() {
            public void run() {
                RedisConnection redisConnection = RedisConnectionUtil.create();
                UUIDServiceRedisImpl uuidServiceRedis = new UUIDServiceRedisImpl();
                uuidServiceRedis.setRedisConnection(redisConnection);
                uuidServiceRedis.setDbIndex(15);
                try {
                    for (int i = 0; i < 100; i++) {
                        System.out.println(new Date() + " get uuid = " + 
                              uuidServiceRedis.fetchUUID("MEMBER", 8, Boolean.TRUE) + 
                              " by globle in " + Thread.currentThread().getName());
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }).start();

        new Thread(new Runnable() {
            public void run() {
                RedisConnection redisConnection = RedisConnectionUtil.create();
                UUIDServiceRedisImpl uuidServiceRedis = new UUIDServiceRedisImpl();
                uuidServiceRedis.setRedisConnection(redisConnection);
                uuidServiceRedis.setDbIndex(15);
                try {
                    for (int i = 0; i < 100; i++) {
                        System.out.println(new Date() + " get uuid = " + 
                            uuidServiceRedis.fetchDailyUUID("ORDER", 8, Boolean.TRUE) + 
                            " by daily in " + Thread.currentThread().getName());
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}
}

执行结果如下

Mon Dec 11 16:14:10 CST 2017 get uuid = 2017121100000003 by member in Thread-32
Mon Dec 11 16:14:10 CST 2017 get uuid = 2017121100000001 by member in Thread-8
Mon Dec 11 16:14:10 CST 2017 get uuid = 2017121100000007 by order in Thread-19
......
Mon Dec 11 16:14:14 CST 2017 get uuid = 2017121100002000 by member in Thread-14
Mon Dec 11 16:14:14 CST 2017 get uuid = 2017121100001999 by member in Thread-16
Mon Dec 11 16:14:14 CST 2017 get uuid = 2017121100001999 by order in Thread-39
Mon Dec 11 16:14:14 CST 2017 get uuid = 2017121100002000 by order in Thread-39

这样,我们就实现了一个满足开始七个需求的一个基本的唯一标识服务。只要调用这个模块的程序连接的 redis 服务器的配置一样,就能实现在同一个对象高效生成唯一标识的基础服务。你还可以将这个包装成为一个 rest 服务,客户端不需要直接连接 redis 服务器,直接通过 rest 的http 服务远程获取唯一标识即可。

这个程序实在前一篇文章《为什么要用缓存服务器以及在 Java 中实现一个 redis 缓存服务》的基础上添加新的实现类的方式完成的。代码同步发布在 GitHub 仓库

;