高可用高性能可扩展的单号生成方案

开发 开发工具
在业务开发中经常会遇到各种单号生成,另外有多少业务量就会至少有多少的单号生成需求,所以单号生成必须高可用,必须高性能。 另外业务不同需要的单号规则可能也不相同, 所以单号服务还必须具备足够的扩展性。

在业务开发中经常会遇到各种单号生成, 例如快递单号、服务单号、订单号等等。 这些单号生成往往是业务逻辑处理的第一步, 单号生成出问题,必然导致业务走不下去;另外有多少业务量就会至少有多少的单号生成需求。所以单号生成必须高可用,必须高性能。 另外业务不同需要的单号规则可能也不相同, 所以单号服务还必须具备足够的扩展性。

一、单号定义

在进入正题之前我们先给单号下个定义, 看几个常见的单号形式。

单号是一个数字和字符组成的序列, 它要满足两个条件: 一个是唯一, 保证唯一才可以作为业务标识; 另一个是符合业务需要的规则。 例如下面三个单号:

  • 2017030400001 这个单号由两个部分序列号日期20170304+定长5位补0数字00001。
  • 010-6541-00001 此单号分三部分, 中间用减号连接, 第一部分为区号, 第二部分为作业单位号码, 第三部分为作业单位产生作业的序号。
  • QJ000001 则是由字符QJ开头后面跟随数字序列的单号。

二、单号数字序列部分的生成

上述单号定义中的数字部分通常是一个自增的数字序列。 我们可以通过数据库的自增列、 数据库的列+1方式、 redis或者memcached的INCR指令来生成这种数字的序列。 这四种方式都可以生成序列, 但各自有各自的好处。

1. 数据库自增列的方式

是通过数据库的内部机制生成的, 在普通PC上每秒约可以生成4000个数字序列, 它的好处是每一个数字序列都会保留一条记录, 记录生成使用时间, 缺点是吞吐量一般, 会占用一定的数据库资源, 如下是一种推荐的表结构:

  1. CREATE TABLE `xx_code_sequence` ( 
  2.    `id` bigint(20) NOT NULL AUTO_INCREMENT, 
  3.    `generate_time` timestamp NOT NULL 
  4.       default CURRENT_TIMESTAMP, 
  5.    PRIMARY KEY (`id`) 
  6. ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULTCHARSET=utf8

此表有两列, id列为bigint类型的自增长字段,作为数字序列的值, generate_time时间戳字段可以记录每一个单号的生成时间。生成数字序列的方式用sql说明如下:

  1. begin trans; 
  2. insert into `xx_code_sequence`(generate_time)values(current_timestamp); 
  3. select last_insert_id(); 
  4. commit; 

说明:

  • 表名格式xx_code_sequence,sto_code_0分为三部分sto为ownerKey, code固定不变,0表示表的序号,可以有多个下标不同表来支持更高的并发,共有几个表需要在开始确认了,确认的依据是需要满足的并发请求。表的个数必须是2的n次方,例如1, 2, 4, 8,16;
  • `id` 即序列的部分值,是通过mysql的自增特性生成的,最终的序列值是id和表序号共同组成的,假定有4个表,序号分别为0,1,2,3;那么序列值为 id<< 2 | table_index; 即id向左移位2位(移位几位取决于表的个数),然后和表序号求或;
  • `generate_time` 为id生成时间,无其他含义。

不同序号的表可以建在不同的数据库上,当某个序号的表不可用时要报警,并切换到其他表上生成数字序列。

2. 数据库的列+1方式

通过对数据库的某列做+1操作, 来得到唯一的数字序列, 是通过数据库的行锁来保障唯一的, 因为涉及到行锁, 所以这种方式生成序列的单行吞吐量不会太大, 适合需要生成多种(每一种放到一行)不同数字生成需求。 如下是一种推荐的表结构:

  1. create table `xx_rowbased_sequence` ( 
  2.    `owner_key` varchar(32) NOT NULL, 
  3.    `current_value` bigint NOT NULL, 
  4.    PRIMARY KEY (`owner_key`) 
  5. ); 

表中的ownerKey列为单号种类标识, current_value为+1操作列。生成序列的方式用sql说明如下

  1. begin trans; 
  2. UPDATE  `xx_rowbased_sequence`SET current_valuecurrent_value=current_value+1 WHERE owner_key=’order-no’; 
  3. SELECT current_value FROM `xx_rowbased_sequence` WHERE owner_key=’order-no’; 
  4. commit; 

需要注意使用此方式生成数字序列事务隔离级别需要是RR。

3. 使用redis/memcached的INCR指令方式

redis/memcached本身可以保证生成数字的唯一性,和高性能。 单一redis服务器每秒可以生成约6w左右的数字序列。 但需要注意redis必须配置主从和存储, 以避免在极端情况下redis节点down机, 导致丢失序列或序列重复。

三、高可用实现

上面介绍了4种生成数字序列的方式, 但要保证高可用, 单靠一种序列生成方式还是不够的, 我们还需要一种高可用的实现。

高可用数字序列生成器内部是2的n次方个底层数字序列生成器, 每个底层序列生成器对应一个下标值, 下标值的范围为[0, 2n-1]。 在生成序列时, 轮询底层生成器, 如果正常, 则将生成结果向左移n位, 并与当前底层序列生成器下标取或得到最终序列值。 如果底层序列生成器发生异常, 则将其标记为不可用, 并轮询下一个底层序列生成器, 直到成功。

高可用实现类com.jd.coo.sa.sequence.ha.BitwiseLoadBalanceSequenceGen,其内部有x个底层SequenceGen实现,此类会轮询的调用底层SequenceGen来生成序列,如果某个底层序列生成出错,会从可用列表中移除掉,被移除掉的底层SequenceGen在过xx时间(默认为5分钟)后,可以重新加入到可用列表中。如果内部序列生成单个序列时间超时,并在最近n时间内连续超时x次,会被移动到异常列表,在异常列表中时间超过xx时间后,也会被重新放入可用列表中。

如果一个底层序列被标记为不可用, 过配置时间后会将其恢复到可用列表中, 自动恢复机制可以避免底层序列生成器已恢复可用, 而程序却一直不使用此底层序列生成器的情况。

高可用实现的内部结构图, 如下图所示:

高可用实现的内部结构图

其核心方法如下所示:

  1. public long gen(String ownerKey){ 
  2.     long sequence=0
  3.     int currentPartitionIndex=-1; 
  4.     SequenceGen innerGen=null
  5.     do{ 
  6.         long startTime=System.currentTimeMillis(); 
  7.         boolean hasError=false
  8.         try{ 
  9.             currentPartitionIndex=getCurrentPartitionIndex(ownerKey); 
  10.             LOGGER.trace("current partition index {}",currentPartitionIndex); 
  11.             innerGen=innerSequences.get(currentPartitionIndex); 
  12.             if(innerGen==SkipSequence.INSTANCE){ 
  13.                 LOGGER.warn("current partition index {} is skipped",currentPartitionIndex); 
  14.                 if(availablePartitionIndices.contains(currentPartitionIndex)){ 
  15.                     LOGGER.warn("current partition index {} is skipped, remove it",currentPartitionIndex); 
  16.                     availablePartitionIndices.remove(Integer.valueOf(currentPartitionIndex)); 
  17.                 } 
  18.  
  19.                 continue; 
  20.             } 
  21.  
  22.             HighAvailablePartitionHolder.setPartition(currentPartitionIndex); 
  23.             sequence=innerGen.gen(ownerKey); 
  24.             onGenNewId(ownerKey,currentPartitionIndex,sequence); 
  25.             LOGGER.trace("genNewId {} with inner {}",sequence,currentPartitionIndex); 
  26.             break; 
  27.         }catch(SequenceOutOfRangeException ex){ 
  28.             LOGGER.error("gen error SequenceOutOfRangeException index {} total available {}", 
  29.                     currentPartitionIndex, 
  30.                     availablePartitionIndices.size()); 
  31.             hasError=true
  32.  
  33.             LOGGER.error("set {} to SKIP",currentPartitionIndex); 
  34.             this.innerSequences.set(currentPartitionIndex,SkipSequence.INSTANCE); 
  35.             onError(ownerKey,currentPartitionIndex,innerGen,ex); 
  36.             LOGGER.error("after onError total available {}/{}",currentPartitionIndex, 
  37.                     availablePartitionIndices.size()); 
  38.  
  39.         }catch(Exception ex){ 
  40.             LOGGER.error("gen error index {} total available {}",currentPartitionIndex, 
  41.                     availablePartitionIndices.size()); 
  42.             LOGGER.error("gen error ",ex); 
  43.             hasError=true
  44.             onError(ownerKey,currentPartitionIndex,innerGen,ex); 
  45.             LOGGER.error("after onError total available {}/{}",currentPartitionIndex, 
  46.                     availablePartitionIndices.size()); 
  47.         }finally{ 
  48.             long usedTime=System.currentTimeMillis()-startTime; 
  49.             boolean isTimeout=usedTime>timeoutThresholdInMilliseconds; 
  50.             if(!hasError&&isTimeout){ 
  51.                 onTimeout(currentPartitionIndex,innerGen,usedTime); 
  52.             } 
  53.             LOGGER.trace("gen usedTime {}",usedTime); 
  54.         } 
  55.     }while(true); 
  56.     return sequence; 

使用时配置bean使用即可, 如下spring bean xml配置:

  1. <bean id="highAvailableSequenceGen" class="com.jd.coo.sa.sequence.ha.BitwiseLoadBalanceSequenceGen"> 
  2.       <!-- 指定高可用序列底层序列生成序列后向左移位位数--> 
  3.     <constructor-arg index="0" value="2"/> 
  4.      <!-- 指定底层序列 --> 
  5.     <constructor-arg index="1"> 
  6.         <map> 
  7.             <!-- key 为底层序列生成值左移位后或的下标--> 
  8.         <entry key="0"> 
  9.                 <bean class="com.jd.coo.sa.sequence.AutoIncrementTablesSequenceGen"> 
  10.                     <property name="dataSource" ref="dataSourceA"/> 
  11.                     <property name="sequenceTableFormat" value="%s_code_%d"/> 
  12.                 </bean> 
  13.             </entry> 
  14.             <entry key="1"> 
  15.                 <bean class="com.jd.coo.sa.sequence.AutoIncrementTablesSequenceGen"> 
  16.                     <property name="dataSource" ref="dataSourceB"/> 
  17.                     <property name="sequenceTableFormat" value="%s_code_%d"/> 
  18.                 </bean> 
  19.             </entry> 
  20.             <entry key="2"> 
  21.                 <bean class="com.jd.coo.sa.sequence.AutoIncrementTablesSequenceGen"> 
  22.                     <property name="dataSource" ref="dataSourceA"/> 
  23.                     <property name="sequenceTableFormat" value="%s_code_%d"/> 
  24.                 </bean> 
  25.             </entry> 
  26.             <entry key="3"> 
  27.                 <bean class="com.jd.coo.sa.sequence.AutoIncrementTablesSequenceGen"> 
  28.                     <property name="dataSource" ref="dataSourceB"/> 
  29.                     <property name="sequenceTableFormat" value="%s_code_%d"/> 
  30.                 </bean> 
  31.             </entry> 
  32.         </map> 
  33.     </constructor-arg> 
  34.     <!-- 将timeout判断的阈值设置为一个很大的值, 避免timeout应用error的情况发生--> 
  35.    <property name="timeoutThresholdInMilliseconds" value="200"/> 
  36.     <!-- 超时多少次后会移出可用列表 --> 
  37.    <property name="timeoutEventCountThreshold" value="3"/> 
  38.     <!-- 计算超时异常的时间周期, 以秒为单位 --> 
  39.    <property name="timeoutTimeThresholdInSeconds" value="60" /> 
  40.     <!-- 移到不可用队列多长时间后会被重新放入可用队列 --> 
  41.    <property name="onErrorRescueThresholdInSeconds" value="2000"/> 
  42. </bean> 

四、高性能实现

单号生成只是业务操作的第一个步骤, 业务操作往往是复杂耗时的, 我们必须保证单号生成的性能, 使其几乎不会影响业务时间。

上述介绍的四种序列生成方式都是跨网络通过中间件获得的序列号,要进一步优化其性能,我们需要将序列放在离CPU更近的地方――内存中。我们使用如下两种方式将数字序列放到CPU更近的地方:

  • 将内部序列值向左移位n位, 然后序列的最右n位在内存生成,一次生成2的n次方个数字序列, 然后放在内存队列中;
  • 异步提前生成:实时计算序列号方法被调用的速度, 然后在异步线程(池)中生成最近x ms需要的序列,放入内存队列中备用

这两种方式并不一定都需要, 置放入内存队列中的数字序列越多,重启时丢失的也会越多。

其内部结构图示如下:

高性能序列使用的bean配置如下:

  1. <bean class="com.jd.coo.sa.sequence.QueuedSequenceGen" id="queuedSequenceGen" init-method="start" destroy-method="stop"> 
  2.     <!-- 指定内部序列, 通常是一个高可用的内部序列--> 
  3.     <constructor-arg index="0" ref="haSequenceGen" /> 
  4.     <!-- 指定内存中生成的bit位数--> 
  5.     <property name="memoryBitLength" value="3"/> 
  6.     <!--异步生成配置--> 
  7.     <property name="enableAsync" value="true"/> 
  8.     <property name="asyncTask"> 
  9.         <bean class="com.jd.coo.sa.sequence.QueuedSequenceGen$AsyncTask"> 
  10.             <constructor-arg index="0" ref="queuedSequenceGen"/> 
  11.             <property name="loopSleepInterval" value="20"/> 
  12.             <property name="reserveTimeInMilliseconds" value="10"/> 
  13.         </bean> 
  14.     </property> 
  15.     <!--结束异步配置--> 
  16. </bean> 

通过设定memoryBitLength,指定序列的最右的memoryBitLength位在内存中生成以提高生成的效率。 需要注意memoryBitLength值越大则在内存中的序列条数越多, 性能越高, 如果发生重启时丢失的序列也会越多, 要根据情况来设置。 支持异步生成序列值, 异步生成的速度会根据序列值消费速度自适应。

五、关于可扩展性

单号规则多种多样, 不能每增加一种规则就增加一个需求, 我们需要相对灵活的扩展性。 上述介绍了多种单号数字序列的生成方式, 和数字序列生成的高可用和高性能实现, 他们都实现了同一个接口:

  1. /** 
  2.  * 根据序列业务类型生成新序列的接口 
  3.  * 
  4.  * 生成序列是大致递增的 
  5.  * 
  6.  * Created by zhaoyukai on 2016/8/8. 
  7.  */ 
  8. public interface SequenceGen { 
  9.     /** 
  10.      * 生成序列 
  11.      * @param ownerKey 序列业务key 
  12.      * @return 新序列值 
  13.      */ 
  14.     long gen(String ownerKey); 

有了这个统一的数字序列生成接口, 我们可以扩展多种不同的数字序列生成方式。 或者实现不同的高可用、高性能机制。

另外在本文的开头我们介绍了多种不同的单号生成规则, 要灵活满足这些不同的规则, 我们使用表达式来表达单号的组合规则, 通过将表达式解析成不同的Expression来实现不同单号部分的生成。 下面我们看一个单号表达式的示例, 如下是一个spring bean配置:

  1. <!-- 单号生成bean, 在应用中注入此bean生成单号 --> 
  2. <bean class="com.jd.coo.sa.sn.SmartSNGen" name="snGen"> 
  3.     <!-- 序列号的表达式, 见下面说明 --> 
  4.     <constructor-arg value="@{ownerKey, value=SN}-@{bean, ref=sequence}-@{com.jd.coo.sa.sn.expression.CheckSumExpression}"/> 
  5.     <!-- 表达式解析器 --> 
  6.     <property name="interpreter"> 
  7.         <!-- 单号生成器的表达式解释器, 固定为SmartInterpreter--> 
  8.         <bean class="com.jd.coo.sa.sn.expression.SmartInterpreter" name="smartInterpreter"/> 
  9.     </property> 
  10. </bean> 

SmartSNGen类负责根据表达式生成不同规则的单号,其构造函数第一个参数值:

 

  1. @{ownerKey, value=SN}-@{bean, ref=sequence}- 

@{com.jd.coo.sa.sn.expression.CheckSumExpression} 即为表达式, 该表达式分为五个部分:

  • @{ownerKey, value=SN} 在表达式生成的上下文中写入key为ownerKey值为SN的参数
  • “-“ 表示静态表达式“-”
  • @{bean, ref=sequence} 指定引用id为sequence的spring bean来生成表达式的一部分
  • “-“表示静态表达式”-“
  • @{com.jd.coo.sa.sn.expression.CheckSumExpression} 表示要创建指定类com.jd.coo.sa.sn.expression.CheckSumExpression的实例来生成表达式的一部分

该bean的interpreter属性指定了表达式的解释器,该解释器会将表达式值转换为实现了Expression接口的对象,通过该对象可以计算出单号的值。

表达式解释器查找表达式中的“@{”和“}”对,将其内部的表达式解析为动态表达式,将其他部分的表达式解析为静态表达式。动态表达式分为三种类型:

  1. spring配置文件中的bean引用表达式
  2. 指定上下文参数的表达式
  3. 指定自定义类型的表达式

第3种表达式留出任意扩展自定义表达式的扩展点。

Expression接口定义如下:

  1. import com.jd.coo.sa.sn.GenContext; 
  2.  
  3. /** 
  4.  * SmartSNGen表达式接口 
  5.  * 
  6.  * Created by zhaoyukai on 2016/10/18. 
  7.  */ 
  8. public interface Expression { 
  9.     /** 
  10.      * 计算表达式的值 
  11.      * @param context 表达式计算上下文, 表达式可以根据需要将计算中间值存储到上下文中, 以便在表达式之间共享数据 
  12.      * @return 表达式计算值 
  13.      */ 
  14.     Object eval(GenContext context); 
  15.  
  16.     /** 
  17.      * 计算优先级, 优先级越高越先执行, 如果表达式需要依赖其他表达式的值, 则要在依赖表达式计算之后执行 
  18.      * @return 执行顺序 
  19.      */ 
  20.     ExecuteOrder executeOrder(); 
  21.  
  22.     /** 
  23.      * 该表达式的最大字符串长度值 
  24.      * 
  25.      * @return 最大长度值 
  26.      */ 
  27.     int maxLength(); 

通过实现此接口即可实现任何自定义的单号生成逻辑。如下是自定义的单号校验位生成表达式示例:

  1. public class CheckSumExpressionimplements Expression { 
  2.     public Object eval(GenContext context) { 
  3.         Long newId = (Long) context.get("sequence"); 
  4.         if (newId == null) { 
  5.             throw newRuntimeException("sequence can not be null when calculate checksum"); 
  6.         } 
  7.         return newId * 9 % 31 % 10; 
  8.     } 
  9.     public ExecuteOrder executeOrder() { 
  10.         return ExecuteOrder.AfterNormal; 
  11.     } 
  12.     public int maxLength() { 
  13.         return 1; 
  14.     } 

总结

本文提到了多种单号数字序列生成方式,还介绍了高可用、高性能以及扩展性的实现方式。

 

  1. 要根据场景, 并发量, 单号类型数量选择数字序列生成方式;
  2. 不要裸奔, 要使用高可用+高性能序列生成器, 保证单号生成方式的可用性和性能;
  3. 底层序列要从物理上做隔离, 否则出现硬件故障高可用机制也会时效;
  4. 使用了多个底层序列生成方式时生成的序列是大致自增, 不能保证完全自增, 这是设计使然, 如果要保证完全自增, 则会出现单点, 在完全自增和单点的选择上, 我们选择了大致自增+非单点;
  5. 高性能序列生成的性能可以通过调节其memoryBitLength属性来提高, 但要根据业务实际情况来做选择,memoryBitLength属性值越高在内存生成的序列数越多,性能越高, 但在进程停止时丢失的序列也会越多。

作者:赵玉开,十年以上互联网研发经验,2013年加入京东,在运营研发部任架构师,期间先后主持了物流系统自动化运维平台、青龙数据监控系统和物流开放平台的研发工作,具有丰富的物流系统业务和架构经验。在此之前在和讯网负责股票基金行情系统的研发工作,具备高并发、高可用互联网应用研发经验。

【本文来自51CTO专栏作者张开涛的微信公众号(开涛的博客),公众号id: kaitao-1234567】

 戳这里,看该作者更多好文

责任编辑:赵宁宁 来源: 51CTO专栏
相关推荐

2021-05-24 09:28:41

软件开发 技术

2017-11-27 09:14:29

2012-07-19 10:59:18

Jav并发

2012-06-13 02:10:46

Java并发

2012-11-14 15:25:58

2022-06-02 12:56:25

容器网络云原生

2020-12-09 09:21:41

微服务架构数据

2011-10-20 15:36:36

高可用高性能MySQL

2017-12-22 09:21:02

API架构实践

2023-03-21 08:01:44

Crimson硬件CPU

2018-03-26 09:02:54

MongoDB高可用架构

2019-08-23 08:09:18

订单号生成数据库ID

2013-03-13 10:08:17

用友UAP高可用高性能

2012-04-17 16:48:43

应用优化负载均衡Array APV

2019-03-01 11:03:22

Lustre高性能计算

2013-04-09 10:16:28

OpenStackGrizzlyHyper-V

2013-06-07 11:30:32

2023-08-22 13:16:00

分布式数据库架构数据存储

2021-07-01 06:58:12

高并发订单号SCM

2022-09-29 15:24:15

MySQL数据库高可用
点赞
收藏

51CTO技术栈公众号