支付系统 - 雪花算法与多键分表

雪花算法与多键分表

前言

本文是对支付系统中平台流水号的生成进行探讨。一般情况下,我们希望生成的字段值满足我们某种期望的,尽量不去使用完全没有规律的值。假设,我们支付系统中平台流水号的值是 20200627130743000001。这个数字前 14 位代表时间戳,表示该笔交易是 2020 年 6 月 27 日 13 时 7 分 43 秒发生的,后面 6 位代表秒内序列,这里的值是000001,表示这是该秒内发生的第一笔交易。可以看出这里 6 位长度的规则设定最多能满足 1 秒接近 100 万笔交易。

如果使用 Oracle 数据库,后 6 位的取值非常容易,直接使用数据库自带的序列机制即可,该序列起始值为 0,最大值 999999,循环使用。跨节点取序列值也不会重复,所以在单一集群分布式环境下也没问题。

但是在常规互联网应用中,大部分应用使用的是免费开源的 Mysql,没有自带序列的机制。且服务多是分布式部署,那么怎么生成唯一 ID 也就成为了一个问题。在业内生成唯一 ID 的服务也被叫做发号器,这个名字挺土的,本文也不想去探讨如何设计一款通用的发号器服务,而是说清楚支付系统中平台流水号的生成细节。

为什么不用 UUID?

UUID 是通用唯一识别码(Universally Unique Identifier)的缩写,能在分布式系统保证唯一性。但是,这个值我们很难应用到我们的业务场景中去,首先无语义是最大的缺点,其次占用存储空间大,缺乏有序性,会影响 Mysql 插入时的性能,这块不做过多探讨,读者有兴趣可参考其他资料。

snowflake 算法是 Twitter 对发号器的一种开源实现,翻译成中文就是「雪花」。使用 Scala 语言实现,Github 地址是 snowflake。本文面向的是 Java 工程师,相信大家在工程中不会去直接使用 Scala,所以一般都会按照其算法重新实现。但是很多朋友一看见其实现就感觉很复杂,我总结了一下原因是因为对二进制和位运算不熟悉。所以,下面先从基础讲起,然后再提供最终版本的实现以供参考。

雪花算法的原理

在展开分析之前,我们先来明确下雪花算法生成的到底是什么。直接说结论,在 Java 中是一个 Long 类型的数值。所有的算法运算过来过去就是为了生成一个 Long 类型数字,另外,Long 类型是 64 位 Bit,也就是由 64 位 0 或者 1 的数字组成。既然是有限位数,那么肯定有最大值和最小值,我们来看一下其转换为二进制后的表示。

/**
 * 转换为二进制,并对其补零,直到达到64位
 */
public static String toBinaryString(long value) {
  String binaryLong = Long.toBinaryString(value);
 while (binaryLong.length() < 64) {  binaryLong = "0" + binaryLong;  }  return binaryLong; } 复制代码

分别调用后我们得到:

Long.MAX_VALUE 的十进制是9223372036854775807,二进制是0111111111111111111111111111111111111111111111111111111111111111

Long.MIN_VALUE 的十进制是-9223372036854775808,的二进制是1000000000000000000000000000000000000000000000000000000000000000

这里需要提醒大家:

64 位中,第一位代表正负 0 + 1 -

扫描二维码关注公众号,回复: 11379230 查看本文章

由于我们一般生成的都是无符号整数,所以这里不用考虑负数的情况。OK,那我想大家都清楚了,我们的目标就是构建一个 64 位的比特序列,其中第一位是 0,剩下 63 位根据一定的规则生成,最后再将其转换为十进制,这样就得到了一个 Long 类型。看下怎么把二进制转换为十进制的 Long 数值:

/**
 * 二进制转为数值
 */
public static long toLong(String binaryString) {
  return Long.valueOf(binaryString, 2); // 2代表原来的进制
} 复制代码

有了这些认知,就可以看看雪花算法具体的规则了。

规则

为了方便大家理解,我画了个图:

雪花算法示意图
雪花算法示意图

这块有了上面的铺垫,我觉得很好理解,0是固定值代表正数,41 位时间戳随时间递增,可以保证我们生成的 Long 类型数值一定是越晚生成的越大,也就是随时间趋势递增的。10位工作机器 ID 其实特别好理解,因为服务是跨节点部署的,需要确定运行该算法的机器信息,这样当不同的服务同时调用时,即使毫秒时间完全一致,这 10 位产生的数值也不同。最后就剩下了12位的序列号,可以参考文章开头时介绍的000001序列,和那个完全是一样的目的,这里就不多说了。

上面的三大块填充到63位的二进制数字中,可以保证在任意时间、任何机器产生的数字都不重复。其中,时间和序列号是不可控的,但是工作机器的信息是可控的,也就是说机器信息是不产生重复的关键。下面具体说说为了实现该算法需要用到的一些细节。可能大家看起来内容似乎是孤立的,但是没关系,先耐着性子看完,有个大概印象,这些都是算法的基础。

时间

时间部分的逻辑很简单,就是规定一个起始时间戳,然后用当前时间减去最终得到一个值。思路很简单,但是这里有两个值的思考的点。

  • 如果机器当前时间发生变化会怎样?
  • 为什么是 41 位?
  • 为什么要减去一个起始时间戳?

OK,一个一个来回答。机器时间发生变化那要看早了还是晚了,如果晚了没事,如果早了对不起你会发现可能出现重复的值了,这是该算法最大的缺点,一定要注意时钟回拨的问题。为了不影响业务,我们在实现的过程中可以和上一个生成的值所用的时间戳进行比较,如果小了说明系统时间回调了则拒绝提供服务。

第二和第三个问题一起回答,先来看看 Java 中时间戳和日期的互转:

long currentTimeMillis = System.currentTimeMillis();
System.out.println(currentTimeMillis);
// 1593243941862
System.out.println(toBinaryString(currentTimeMillis));
// 0000000000000000000000010111001011110100101111010011101111100110
Date date = new Date(currentTimeMillis); System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date)); // 2020-06-27 15:45:41 复制代码

可以看到,当前时间戳获取的是 Long 类型,转换为二进制后永远是41位,我这里是按照64的标准去补的零。

我们都知道41位的长度也是有最大值的,也就是411转换成时间戳后的日期是多少呢?

long maxTimeMillis = toLong("11111111111111111111111111111111111111111");
System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(maxTimeMillis)));
// 2039-09-07 23:47:35
复制代码

答案是2039-09-07 23:47:35,也就是说到了这一天我们的发号器就不能用了。那怎么延长这个时间呢?笨,用当前时间减去一个数字它不就变小了吗?那减去多少合适呢,不能随便给个数字吧?这就变成了一个数学问题,请听题:

已知 C <= M,想要 C - I <= M,求 I 的最大值?
C:当前时间,M:最大时间,I:待减去的时间。

额,太难了,我感觉大家也看不懂(Dog Head),这里直接给出答案吧,那就是 I = M - C。也就是说这个初始时间和我们当前时间有关。

用当前时间带入公式一算,得到初始I1989-03-13 15:15:17,大家也可以试试,不过随着时间的推移这个值只会越来越小。这里我们就使用固定日期2015-01-01 00:00:00,它对应的时间戳是1479692912967

如此,有了这个初始时间,使用当前时间减去初始时间就能得到一个Long类型的数字,可以通过移位得到我们期望的41位比特,看一下伪代码吧。

// 起始时间
private static final long snsEpoch = 1479692912967L;
long timestamp =  System.currentTimeMillis();

  // 不允许时钟回拨
 if (timestamp < this.lastTimestamp) {  throw new RuntimeException(String.format(  "Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));  }  // 上次生成id的时间戳 lastTimestamp = timestamp;  // 时间部分 long timePart = timestamp - snsEpoch; 复制代码

机器信息

机器信息,算法中的实现将这10位分为了5位数据中心(DATA_ID)位和5位机器 Id(WOKER_ID)位。数据中心我说一句话你体会一下:「两地三中心、异地容灾、跨海岸部署」,对就是这个意思。机器 Id 其实就是机器唯一标识,使用的算法自定义,一般都是对机器信息进行算数运算得到一个值。如下所示,就是把机器 IP 和机器名称字符串转变成了一个数字。

// 数据中心
private static final long DATA_ID = dataIdGen();
// 机器
private static final long WOKER_ID = workIdGen();

/**  * 数据中心Id,使用hostname(机器名)取余,最大值为5个bit的最大值11111 = 31  */ private static long dataIdGen() {  try {  return getHostId(Inet4Address.getLocalHost().getHostName(), 31);  } catch (UnknownHostException e) {  return ThreadLocalRandom.current().nextLong(32);  } }  /**  * 机器Id,使用hostadddress(IP地址)取余,最大值为5个bit的最大值11111 = 31  */ private static long workIdGen() {  try {  return getHostId(Inet4Address.getLocalHost().getHostAddress(), 31);  } catch (UnknownHostException e) {  return ThreadLocalRandom.current().nextLong(32);  } }  /**  * 对字符串取余,获得的结果最大为max<br>  * 为了把字符串变成数字  */ private static long getHostId(String value, int max) {  byte[] bytes = value.getBytes();  int sum = 0;  for (byte b : bytes) {  sum += b;  }  return sum % (max + 1); } 复制代码

毫秒内序列

终于来到了最后一部分,毫秒内序列,这块是算法最多 1 毫秒内生成数量的关键。这一部分长度为12位,十进制为4095。也就是 1 毫秒内支持4095笔。如果超过了怎么办?很简单让系统等一等,等到下一毫秒再生成即可,简单粗暴。

OK,在这里看一下完整的实现。

// 起始时间
private static final long snsEpoch = 1479692912967L;
// 上次生成id的时间戳
private long lastTimestamp = -1L;
// 序列号(如果同一毫秒并发需要使用)
private long lastSequence = 0L; // 数据中心 private static final long DATA_ID = dataIdGen(); // 机器 private static final long WOKER_ID = workIdGen();   public synchronized long nextId() {  long timestamp = timeGen();   // 不允许时钟回拨  if (timestamp < this.lastTimestamp) {  throw new RuntimeException(String.format(  "Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));  }   // 上一次请求的时间戳和此刻相等,需要生成序列号  if (this.lastTimestamp == timestamp) {  lastSequence = lastSequence + 1;  if (lastSequence > 4095L) {  timestamp = nextMill(lastTimestamp); // 下一毫秒  lastSequence = 0L;  }  } else {  lastSequence = 0L;  }   // 上次生成id的时间戳  lastTimestamp = timestamp;   // 时间部分  long timePart = timestamp - snsEpoch;   return (timePart << 22) | (DATA_ID << 17) | (WOKER_ID << 12) | lastSequence; }  /**  * 获取当前毫秒数  */ private long timeGen() {  return System.currentTimeMillis(); }  /**  * 下一毫秒  */ private long nextMill(long lastMill) {  long cur = System.currentTimeMillis();  while (lastMill <= cur) {  cur = System.currentTimeMillis();  }  return cur; }  /**  * 数据中心Id,使用hostname(机器名)取余,值最大为5个bit最大值11111 = 31  */ private static long dataIdGen() {  try {  return getHostId(Inet4Address.getLocalHost().getHostName(), 31);  } catch (UnknownHostException e) {  return ThreadLocalRandom.current().nextLong(32);  } }  /**  * 机器Id,使用hostadddress(IP地址)取余,获得的值最大为5个bit最大值11111 = 31  */ private static long workIdGen() {  try {  return getHostId(Inet4Address.getLocalHost().getHostAddress(), 31);  } catch (UnknownHostException e) {  return ThreadLocalRandom.current().nextLong(32);  } }  /**  * 对字符串取余,获得的结果最大为max<br>  * 为了将字符串转成一个Long  */ private static long getHostId(String value, int max) {  byte[] bytes = value.getBytes();  int sum = 0;  for (byte b : bytes) {  sum += b;  }  return sum % (max + 1); } 复制代码

最终这里出现了一些移位操作,是因为我们生成的4个 Key 都是 Long 类型即64位的,需要移动到对应的位置。移动后的空位后面都会补零,然后进行或运算,这样不影响原来 key 的值,最后再转为 Long 就得到了我们需要的唯一 Id。但是这种实现和网上的还是有所差别,其实就是加入了一些位运算本质没什么区别。

位运算基础

为了方便描述,我用表格的形式来表示:

符号 名称 简介 示例
& 只要有一个是 0 就是 0 101&001=001
| 只要有一个是 1 就是 1 101|001=101
^ 异或 只有 1 和 0,才会是 1 101^001=100
~ 取反 取反,所在位 1 变 0 ,0 变 1 ~101=010

最终版实现

这里直接给出一个版本,供参考。


import java.net.Inet4Address;
import java.net.UnknownHostException;
import java.util.Random;

public class SnowflakeUtils {   /** 时间部分所占长度 */  private static final int TIME_LEN = 41;  /** 数据中心id所占长度 */  private static final int DATA_LEN = 5;  /** 机器id所占长度 */  private static final int WORK_LEN = 5;  /** 毫秒内序列所占长度 */  private static final int SEQ_LEN = 12;   /** 定义起始时间 2015-01-01 00:00:00 */  private static final long START_TIME = 1420041600000L;  /** 上次生成ID的时间截 */  private static long LAST_TIME_STAMP = -1L;  /** 时间部分向左移动的位数 22 */  private static final int TIME_LEFT_BIT = 64 - 1 - TIME_LEN;   /** 自动获取数据中心id(可以手动定义 0-31之间的数) */  private static final long DATA_ID = getDataId();  /** 自动机器id(可以手动定义 0-31之间的数) */  private static final long WORK_ID = getWorkId();  /** 数据中心id最大值 31 */  private static final int DATA_MAX_NUM = ~(-1 << DATA_LEN);  /** 机器id最大值 31 */  private static final int WORK_MAX_NUM = ~(-1 << WORK_LEN);  /** 随机获取数据中心id的参数 32 */  private static final int DATA_RANDOM = DATA_MAX_NUM + 1;  /** 随机获取机器id的参数 32 */  private static final int WORK_RANDOM = WORK_MAX_NUM + 1;  /** 数据中心id左移位数 17 */  private static final int DATA_LEFT_BIT = TIME_LEFT_BIT - DATA_LEN;  /** 机器id左移位数 12 */  private static final int WORK_LEFT_BIT = DATA_LEFT_BIT - WORK_LEN;   /** 上一次的毫秒内序列值 */  private static long LAST_SEQ = 0L;  /** 毫秒内序列的最大值 4095 */  private static final long SEQ_MAX_NUM = ~(-1 << SEQ_LEN);   public synchronized static long genId() {  long now = System.currentTimeMillis();   // 如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常  if (now < LAST_TIME_STAMP) {  throw new RuntimeException(String.format(  "Clock moved backwards. Refusing to generate id for %d milliseconds", LAST_TIME_STAMP - now));  }   if (now == LAST_TIME_STAMP) {  LAST_SEQ = (LAST_SEQ + 1) & SEQ_MAX_NUM;  if (LAST_SEQ == 0) {  now = nextMillis(LAST_TIME_STAMP);  }  } else {  LAST_SEQ = 0;  }   // 上次生成ID的时间截  LAST_TIME_STAMP = now;   return ((now - START_TIME) << TIME_LEFT_BIT) | (DATA_ID << DATA_LEFT_BIT) | (WORK_ID << WORK_LEFT_BIT)  | LAST_SEQ;  }   /**  * 获取下一不同毫秒的时间戳,不能与最后的时间戳一样  */  public static long nextMillis(long lastMillis) {  long now = System.currentTimeMillis();  while (now <= lastMillis) {  now = System.currentTimeMillis();  }  return now;  }   /**  * 获取字符串s的字节数组,然后将数组的元素相加,对(max+1)取余  */  private static int getHostId(String s, int max) {  byte[] bytes = s.getBytes();  int sums = 0;  for (int b : bytes) {  sums += b;  }  return sums % (max + 1);  }   /**  * 根据 host address 取余,发生异常就获取 0到31之间的随机数  */  public static int getWorkId() {  try {  return getHostId(Inet4Address.getLocalHost().getHostAddress(), WORK_MAX_NUM);  } catch (UnknownHostException e) {  return new Random().nextInt(WORK_RANDOM);  }  }   /**  * 根据 host name 取余,发生异常就获取 0到31之间的随机数  */  public static int getDataId() {  try {  return getHostId(Inet4Address.getLocalHost().getHostName(), DATA_MAX_NUM);  } catch (UnknownHostException e) {  return new Random().nextInt(DATA_RANDOM);  }  }  } 复制代码

多键分表

终于来到了第二个问题,其实本文写的初衷是想讲一下分表时如果有多个键如何将它们落到一张表中,结果花了大部分的篇幅在介绍雪花算法。

问题背景

我们知道在支付系统中,如果只使用一个字段orderId来进行下单操作,那是不够的。因为当端上唤起支付时就会创建一笔预支付订单,一般而言一笔业务订单用户可能会发起多次收银台支付操作(想想剁手时点了支付弹出输入密码,又退出去过了两小时还是没忍住买了的快乐)。如果支付系统依靠此业务订单来做幂等,那显然是和业务冲突的。所以,一般而言还会有另一个幂等字段bus_unique_id。嗯,上面的介绍就派上用场了,这个字段一般就是用发号器生成的。每次唤起收银台请求支付系统传入相同的orderId和不同的bus_unique_id就可以了,很完美。

问题出现在支付系统中,一般而言我们的支付流水表都是按照一定规则进行分表的。如果是这样,那这个分表规则一定和bus_unique_id有关系,原因是业务需要通过此字段来查询支付流水。我们支付系统中也会有平台流水号serial_no的概念,是我们内部使用发号器生成的,这个字段业务也会作为唯一标识来查询流水结果。所以bus_unique_idserial_no就必须按照一定的算法落到一张表中,因为查询接口肯定是这两个字段都可以传的,不可能我们内部查两张表最后组合出结果返给业务,这样太傻了。

定制发号器

OK,本文不讨论分表的实现,只讨论算法。

假设我们有16张分表,p_mer_pay_${0..15}一般而言取模16即可路由到一张表中。bus_unique_id使用此规则,并且这个字段是业务传上来的我们无法控制,一般不会进行再次运算修改原始数据。所以,问题就转换为了serial_no的生成规则如何和bus_unique_id发生关联。

这里我的实现是这样的:

//先使用发号器生成唯一ID
Long preID = UniqueIDProvider.getUniqueID();
//然后清空低7位
preID = (preID >> 7) << 7;
//hash为了保证传入bus_unique_id的均匀。
long hashcode = hash(bus_unique_id); //57位拼上hash后的低7位 long orderid = preID | (hashcode & 127); 复制代码
public static long hash(Long key) {
    byte[] keys = String.valueof(key).getBytes(Charset.forName("utf-8"));
    int hashcode = new HashCodeBuilder().append(keys).toHashCode();
    return hashcode & 0x7FFFFFFF;
}
复制代码

其中 hash 算法中,16进制的0x7FFFFFFF其实就是 Integer.MAX_VALUE,转换为二进制为0000000000000000000000000000000001111111111111111111111111111111

127转换为二进制为低7位是..00001111111,高位都是0。所以使用hashcode & 127有0即0,最终只会有后7位的值保留,前面的高位全部变为0

preID的后7位都是0,这样通过或运算有1即1,不影响preID57位的值,后面的0刚好又不影响运算右侧的值。这样就拼成了64位的新值。

可能有的朋友要问了,为什么要操作低7位呢?这样为什么会和bus_unique_id的 Key 落到一张表里呢?

问得好,你可真是个好奇宝宝,先来看一个黑科技:

其实十进制数字 M % N 的结果取决于该数字 M 转为二进制后 logN 的值

举个栗子:

Integer count = 0;
for (;;) {
  long currentTimeMillis = System.currentTimeMillis();
  if (currentTimeMillis % 16 == 0) {
    System.out.println(toBinaryString(currentTimeMillis));
 count++;  }  if (count == 10) {  break;  } } // 0000000000000000000000010111001011110101011011001100011101110000 // 0000000000000000000000010111001011110101011011001100011101110000 // 0000000000000000000000010111001011110101011011001100011101110000 // 0000000000000000000000010111001011110101011011001100011101110000 // 0000000000000000000000010111001011110101011011001100011101110000 // 0000000000000000000000010111001011110101011011001100011101110000 // 0000000000000000000000010111001011110101011011001100011101110000 // 0000000000000000000000010111001011110101011011001100011101110000 // 0000000000000000000000010111001011110101011011001100011101110000 // 0000000000000000000000010111001011110101011011001100011101110000 复制代码

log16的值为4,你可以仔细观察,它们的低4位是不是都是0000,不信你可以自己做个实验,模模别的数字试试。结论就是,取模结果一样的,它们的低logN位也是一样的。这里取得是结果为0的例子,所以低位都是0,你可以试试currentTimeMillis % 16 == 1的情况,虽然低位不同,但结论是一样的。

然后你想想,serial_nobus_unique_id的低7位一样意味着什么?那可不就意味着它们可以模2^7时会得到同样的值吗?也就是说我们的分表规则最大支持128张表。但是我们不是模的16吗?不错,你想想128是不是16的倍数,直接说结论吧,如果一个Long类型的数字模16结果为0,那么低4位是0000;如果让它去模128,那低8位是00000000。那现在低7位都一样了,低4位难道还不一样?

如此,以后你按照2的倍数去扩展表,自然就不会影响之前落表的结果。不会说你改了算法,之前落到p_mer_pay_0表里的数据现在要落到p_mer_pay_1里,如果是这样的话,朋友今年的年终奖没了。

后语

本文都是本人经验的一些总结,难免疏漏甚至是错误,如果有不合理、不足、可以改进之处,还望指正,谢谢大家。此外,如果有没有讲清楚的地方,希望大家留言探讨。互联网技术变化太快,很多技术我们不可能机械式的记忆。若干年后希望看到此文时能快速回想起来,免得到处找资料的麻烦。

猜你喜欢

转载自juejin.im/post/5ef72f00e51d4534b0053766
今日推荐