高可用高性能可擴(kuò)展的單號(hào)生成方案
在業(yè)務(wù)開(kāi)發(fā)中經(jīng)常會(huì)遇到各種單號(hào)生成, 例如快遞單號(hào)、服務(wù)單號(hào)、訂單號(hào)等等。 這些單號(hào)生成往往是業(yè)務(wù)邏輯處理的第一步, 單號(hào)生成出問(wèn)題,必然導(dǎo)致業(yè)務(wù)走不下去;另外有多少業(yè)務(wù)量就會(huì)至少有多少的單號(hào)生成需求。所以單號(hào)生成必須高可用,必須高性能。 另外業(yè)務(wù)不同需要的單號(hào)規(guī)則可能也不相同, 所以單號(hào)服務(wù)還必須具備足夠的擴(kuò)展性。
一、單號(hào)定義
在進(jìn)入正題之前我們先給單號(hào)下個(gè)定義, 看幾個(gè)常見(jiàn)的單號(hào)形式。
單號(hào)是一個(gè)數(shù)字和字符組成的序列, 它要滿足兩個(gè)條件: 一個(gè)是唯一, 保證唯一才可以作為業(yè)務(wù)標(biāo)識(shí); 另一個(gè)是符合業(yè)務(wù)需要的規(guī)則。 例如下面三個(gè)單號(hào):
- 2017030400001 這個(gè)單號(hào)由兩個(gè)部分序列號(hào)日期20170304+定長(zhǎng)5位補(bǔ)0數(shù)字00001。
- 010-6541-00001 此單號(hào)分三部分, 中間用減號(hào)連接, 第一部分為區(qū)號(hào), 第二部分為作業(yè)單位號(hào)碼, 第三部分為作業(yè)單位產(chǎn)生作業(yè)的序號(hào)。
- QJ000001 則是由字符QJ開(kāi)頭后面跟隨數(shù)字序列的單號(hào)。
二、單號(hào)數(shù)字序列部分的生成
上述單號(hào)定義中的數(shù)字部分通常是一個(gè)自增的數(shù)字序列。 我們可以通過(guò)數(shù)據(jù)庫(kù)的自增列、 數(shù)據(jù)庫(kù)的列+1方式、 redis或者memcached的INCR指令來(lái)生成這種數(shù)字的序列。 這四種方式都可以生成序列, 但各自有各自的好處。
1. 數(shù)據(jù)庫(kù)自增列的方式
是通過(guò)數(shù)據(jù)庫(kù)的內(nèi)部機(jī)制生成的, 在普通PC上每秒約可以生成4000個(gè)數(shù)字序列, 它的好處是每一個(gè)數(shù)字序列都會(huì)保留一條記錄, 記錄生成使用時(shí)間, 缺點(diǎn)是吞吐量一般, 會(huì)占用一定的數(shù)據(jù)庫(kù)資源, 如下是一種推薦的表結(jié)構(gòu):
- CREATE TABLE `xx_code_sequence` (
- `id` bigint(20) NOT NULL AUTO_INCREMENT,
- `generate_time` timestamp NOT NULL
- default CURRENT_TIMESTAMP,
- PRIMARY KEY (`id`)
- ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULTCHARSET=utf8;
此表有兩列, id列為bigint類型的自增長(zhǎng)字段,作為數(shù)字序列的值, generate_time時(shí)間戳字段可以記錄每一個(gè)單號(hào)的生成時(shí)間。生成數(shù)字序列的方式用sql說(shuō)明如下:
- begin trans;
- insert into `xx_code_sequence`(generate_time)values(current_timestamp);
- select last_insert_id();
- commit;
說(shuō)明:
- 表名格式xx_code_sequence,sto_code_0分為三部分sto為ownerKey, code固定不變,0表示表的序號(hào),可以有多個(gè)下標(biāo)不同表來(lái)支持更高的并發(fā),共有幾個(gè)表需要在開(kāi)始確認(rèn)了,確認(rèn)的依據(jù)是需要滿足的并發(fā)請(qǐng)求。表的個(gè)數(shù)必須是2的n次方,例如1, 2, 4, 8,16;
- `id` 即序列的部分值,是通過(guò)mysql的自增特性生成的,最終的序列值是id和表序號(hào)共同組成的,假定有4個(gè)表,序號(hào)分別為0,1,2,3;那么序列值為 id<< 2 | table_index; 即id向左移位2位(移位幾位取決于表的個(gè)數(shù)),然后和表序號(hào)求或;
- `generate_time` 為id生成時(shí)間,無(wú)其他含義。
不同序號(hào)的表可以建在不同的數(shù)據(jù)庫(kù)上,當(dāng)某個(gè)序號(hào)的表不可用時(shí)要報(bào)警,并切換到其他表上生成數(shù)字序列。
2. 數(shù)據(jù)庫(kù)的列+1方式
通過(guò)對(duì)數(shù)據(jù)庫(kù)的某列做+1操作, 來(lái)得到唯一的數(shù)字序列, 是通過(guò)數(shù)據(jù)庫(kù)的行鎖來(lái)保障唯一的, 因?yàn)樯婕暗叫墟i, 所以這種方式生成序列的單行吞吐量不會(huì)太大, 適合需要生成多種(每一種放到一行)不同數(shù)字生成需求。 如下是一種推薦的表結(jié)構(gòu):
- create table `xx_rowbased_sequence` (
- `owner_key` varchar(32) NOT NULL,
- `current_value` bigint NOT NULL,
- PRIMARY KEY (`owner_key`)
- );
表中的ownerKey列為單號(hào)種類標(biāo)識(shí), current_value為+1操作列。生成序列的方式用sql說(shuō)明如下
- begin trans;
- UPDATE `xx_rowbased_sequence`SET current_valuecurrent_value=current_value+1 WHERE owner_key=’order-no’;
- SELECT current_value FROM `xx_rowbased_sequence` WHERE owner_key=’order-no’;
- commit;
需要注意使用此方式生成數(shù)字序列事務(wù)隔離級(jí)別需要是RR。
3. 使用redis/memcached的INCR指令方式
redis/memcached本身可以保證生成數(shù)字的唯一性,和高性能。 單一redis服務(wù)器每秒可以生成約6w左右的數(shù)字序列。 但需要注意redis必須配置主從和存儲(chǔ), 以避免在極端情況下redis節(jié)點(diǎn)down機(jī), 導(dǎo)致丟失序列或序列重復(fù)。
三、高可用實(shí)現(xiàn)
上面介紹了4種生成數(shù)字序列的方式, 但要保證高可用, 單靠一種序列生成方式還是不夠的, 我們還需要一種高可用的實(shí)現(xiàn)。
高可用數(shù)字序列生成器內(nèi)部是2的n次方個(gè)底層數(shù)字序列生成器, 每個(gè)底層序列生成器對(duì)應(yīng)一個(gè)下標(biāo)值, 下標(biāo)值的范圍為[0, 2n-1]。 在生成序列時(shí), 輪詢底層生成器, 如果正常, 則將生成結(jié)果向左移n位, 并與當(dāng)前底層序列生成器下標(biāo)取或得到最終序列值。 如果底層序列生成器發(fā)生異常, 則將其標(biāo)記為不可用, 并輪詢下一個(gè)底層序列生成器, 直到成功。
高可用實(shí)現(xiàn)類com.jd.coo.sa.sequence.ha.BitwiseLoadBalanceSequenceGen,其內(nèi)部有x個(gè)底層SequenceGen實(shí)現(xiàn),此類會(huì)輪詢的調(diào)用底層SequenceGen來(lái)生成序列,如果某個(gè)底層序列生成出錯(cuò),會(huì)從可用列表中移除掉,被移除掉的底層SequenceGen在過(guò)xx時(shí)間(默認(rèn)為5分鐘)后,可以重新加入到可用列表中。如果內(nèi)部序列生成單個(gè)序列時(shí)間超時(shí),并在最近n時(shí)間內(nèi)連續(xù)超時(shí)x次,會(huì)被移動(dòng)到異常列表,在異常列表中時(shí)間超過(guò)xx時(shí)間后,也會(huì)被重新放入可用列表中。
如果一個(gè)底層序列被標(biāo)記為不可用, 過(guò)配置時(shí)間后會(huì)將其恢復(fù)到可用列表中, 自動(dòng)恢復(fù)機(jī)制可以避免底層序列生成器已恢復(fù)可用, 而程序卻一直不使用此底層序列生成器的情況。
高可用實(shí)現(xiàn)的內(nèi)部結(jié)構(gòu)圖, 如下圖所示:
其核心方法如下所示:
- public long gen(String ownerKey){
- long sequence=0;
- int currentPartitionIndex=-1;
- SequenceGen innerGen=null;
- do{
- long startTime=System.currentTimeMillis();
- boolean hasError=false;
- try{
- currentPartitionIndex=getCurrentPartitionIndex(ownerKey);
- LOGGER.trace("current partition index {}",currentPartitionIndex);
- innerGen=innerSequences.get(currentPartitionIndex);
- if(innerGen==SkipSequence.INSTANCE){
- LOGGER.warn("current partition index {} is skipped",currentPartitionIndex);
- if(availablePartitionIndices.contains(currentPartitionIndex)){
- LOGGER.warn("current partition index {} is skipped, remove it",currentPartitionIndex);
- availablePartitionIndices.remove(Integer.valueOf(currentPartitionIndex));
- }
- continue;
- }
- HighAvailablePartitionHolder.setPartition(currentPartitionIndex);
- sequence=innerGen.gen(ownerKey);
- onGenNewId(ownerKey,currentPartitionIndex,sequence);
- LOGGER.trace("genNewId {} with inner {}",sequence,currentPartitionIndex);
- break;
- }catch(SequenceOutOfRangeException ex){
- LOGGER.error("gen error SequenceOutOfRangeException index {} total available {}",
- currentPartitionIndex,
- availablePartitionIndices.size());
- hasError=true;
- LOGGER.error("set {} to SKIP",currentPartitionIndex);
- this.innerSequences.set(currentPartitionIndex,SkipSequence.INSTANCE);
- onError(ownerKey,currentPartitionIndex,innerGen,ex);
- LOGGER.error("after onError total available {}/{}",currentPartitionIndex,
- availablePartitionIndices.size());
- }catch(Exception ex){
- LOGGER.error("gen error index {} total available {}",currentPartitionIndex,
- availablePartitionIndices.size());
- LOGGER.error("gen error ",ex);
- hasError=true;
- onError(ownerKey,currentPartitionIndex,innerGen,ex);
- LOGGER.error("after onError total available {}/{}",currentPartitionIndex,
- availablePartitionIndices.size());
- }finally{
- long usedTime=System.currentTimeMillis()-startTime;
- boolean isTimeout=usedTime>timeoutThresholdInMilliseconds;
- if(!hasError&&isTimeout){
- onTimeout(currentPartitionIndex,innerGen,usedTime);
- }
- LOGGER.trace("gen usedTime {}",usedTime);
- }
- }while(true);
- return sequence;
- }
使用時(shí)配置bean使用即可, 如下spring bean xml配置:
- <bean id="highAvailableSequenceGen" class="com.jd.coo.sa.sequence.ha.BitwiseLoadBalanceSequenceGen">
- <!-- 指定高可用序列底層序列生成序列后向左移位位數(shù)-->
- <constructor-arg index="0" value="2"/>
- <!-- 指定底層序列 -->
- <constructor-arg index="1">
- <map>
- <!-- key 為底層序列生成值左移位后或的下標(biāo)-->
- <entry key="0">
- <bean class="com.jd.coo.sa.sequence.AutoIncrementTablesSequenceGen">
- <property name="dataSource" ref="dataSourceA"/>
- <property name="sequenceTableFormat" value="%s_code_%d"/>
- </bean>
- </entry>
- <entry key="1">
- <bean class="com.jd.coo.sa.sequence.AutoIncrementTablesSequenceGen">
- <property name="dataSource" ref="dataSourceB"/>
- <property name="sequenceTableFormat" value="%s_code_%d"/>
- </bean>
- </entry>
- <entry key="2">
- <bean class="com.jd.coo.sa.sequence.AutoIncrementTablesSequenceGen">
- <property name="dataSource" ref="dataSourceA"/>
- <property name="sequenceTableFormat" value="%s_code_%d"/>
- </bean>
- </entry>
- <entry key="3">
- <bean class="com.jd.coo.sa.sequence.AutoIncrementTablesSequenceGen">
- <property name="dataSource" ref="dataSourceB"/>
- <property name="sequenceTableFormat" value="%s_code_%d"/>
- </bean>
- </entry>
- </map>
- </constructor-arg>
- <!-- 將timeout判斷的閾值設(shè)置為一個(gè)很大的值, 避免timeout應(yīng)用error的情況發(fā)生-->
- <property name="timeoutThresholdInMilliseconds" value="200"/>
- <!-- 超時(shí)多少次后會(huì)移出可用列表 -->
- <property name="timeoutEventCountThreshold" value="3"/>
- <!-- 計(jì)算超時(shí)異常的時(shí)間周期, 以秒為單位 -->
- <property name="timeoutTimeThresholdInSeconds" value="60" />
- <!-- 移到不可用隊(duì)列多長(zhǎng)時(shí)間后會(huì)被重新放入可用隊(duì)列 -->
- <property name="onErrorRescueThresholdInSeconds" value="2000"/>
- </bean>
四、高性能實(shí)現(xiàn)
單號(hào)生成只是業(yè)務(wù)操作的第一個(gè)步驟, 業(yè)務(wù)操作往往是復(fù)雜耗時(shí)的, 我們必須保證單號(hào)生成的性能, 使其幾乎不會(huì)影響業(yè)務(wù)時(shí)間。
上述介紹的四種序列生成方式都是跨網(wǎng)絡(luò)通過(guò)中間件獲得的序列號(hào),要進(jìn)一步優(yōu)化其性能,我們需要將序列放在離CPU更近的地方――內(nèi)存中。我們使用如下兩種方式將數(shù)字序列放到CPU更近的地方:
- 將內(nèi)部序列值向左移位n位, 然后序列的最右n位在內(nèi)存生成,一次生成2的n次方個(gè)數(shù)字序列, 然后放在內(nèi)存隊(duì)列中;
- 異步提前生成:實(shí)時(shí)計(jì)算序列號(hào)方法被調(diào)用的速度, 然后在異步線程(池)中生成最近x ms需要的序列,放入內(nèi)存隊(duì)列中備用
這兩種方式并不一定都需要, 置放入內(nèi)存隊(duì)列中的數(shù)字序列越多,重啟時(shí)丟失的也會(huì)越多。
其內(nèi)部結(jié)構(gòu)圖示如下:
高性能序列使用的bean配置如下:
- <bean class="com.jd.coo.sa.sequence.QueuedSequenceGen" id="queuedSequenceGen" init-method="start" destroy-method="stop">
- <!-- 指定內(nèi)部序列, 通常是一個(gè)高可用的內(nèi)部序列-->
- <constructor-arg index="0" ref="haSequenceGen" />
- <!-- 指定內(nèi)存中生成的bit位數(shù)-->
- <property name="memoryBitLength" value="3"/>
- <!--異步生成配置-->
- <property name="enableAsync" value="true"/>
- <property name="asyncTask">
- <bean class="com.jd.coo.sa.sequence.QueuedSequenceGen$AsyncTask">
- <constructor-arg index="0" ref="queuedSequenceGen"/>
- <property name="loopSleepInterval" value="20"/>
- <property name="reserveTimeInMilliseconds" value="10"/>
- </bean>
- </property>
- <!--結(jié)束異步配置-->
- </bean>
通過(guò)設(shè)定memoryBitLength,指定序列的最右的memoryBitLength位在內(nèi)存中生成以提高生成的效率。 需要注意memoryBitLength值越大則在內(nèi)存中的序列條數(shù)越多, 性能越高, 如果發(fā)生重啟時(shí)丟失的序列也會(huì)越多, 要根據(jù)情況來(lái)設(shè)置。 支持異步生成序列值, 異步生成的速度會(huì)根據(jù)序列值消費(fèi)速度自適應(yīng)。
五、關(guān)于可擴(kuò)展性
單號(hào)規(guī)則多種多樣, 不能每增加一種規(guī)則就增加一個(gè)需求, 我們需要相對(duì)靈活的擴(kuò)展性。 上述介紹了多種單號(hào)數(shù)字序列的生成方式, 和數(shù)字序列生成的高可用和高性能實(shí)現(xiàn), 他們都實(shí)現(xiàn)了同一個(gè)接口:
- /**
- * 根據(jù)序列業(yè)務(wù)類型生成新序列的接口
- *
- * 生成序列是大致遞增的
- *
- * Created by zhaoyukai on 2016/8/8.
- */
- public interface SequenceGen {
- /**
- * 生成序列
- * @param ownerKey 序列業(yè)務(wù)key
- * @return 新序列值
- */
- long gen(String ownerKey);
- }
有了這個(gè)統(tǒng)一的數(shù)字序列生成接口, 我們可以擴(kuò)展多種不同的數(shù)字序列生成方式。 或者實(shí)現(xiàn)不同的高可用、高性能機(jī)制。
另外在本文的開(kāi)頭我們介紹了多種不同的單號(hào)生成規(guī)則, 要靈活滿足這些不同的規(guī)則, 我們使用表達(dá)式來(lái)表達(dá)單號(hào)的組合規(guī)則, 通過(guò)將表達(dá)式解析成不同的Expression來(lái)實(shí)現(xiàn)不同單號(hào)部分的生成。 下面我們看一個(gè)單號(hào)表達(dá)式的示例, 如下是一個(gè)spring bean配置:
- <!-- 單號(hào)生成bean, 在應(yīng)用中注入此bean生成單號(hào) -->
- <bean class="com.jd.coo.sa.sn.SmartSNGen" name="snGen">
- <!-- 序列號(hào)的表達(dá)式, 見(jiàn)下面說(shuō)明 -->
- <constructor-arg value="@{ownerKey, value=SN}-@{bean, ref=sequence}-@{com.jd.coo.sa.sn.expression.CheckSumExpression}"/>
- <!-- 表達(dá)式解析器 -->
- <property name="interpreter">
- <!-- 單號(hào)生成器的表達(dá)式解釋器, 固定為SmartInterpreter-->
- <bean class="com.jd.coo.sa.sn.expression.SmartInterpreter" name="smartInterpreter"/>
- </property>
- </bean>
SmartSNGen類負(fù)責(zé)根據(jù)表達(dá)式生成不同規(guī)則的單號(hào),其構(gòu)造函數(shù)第一個(gè)參數(shù)值:
- @{ownerKey, value=SN}-@{bean, ref=sequence}-
@{com.jd.coo.sa.sn.expression.CheckSumExpression} 即為表達(dá)式, 該表達(dá)式分為五個(gè)部分:
- @{ownerKey, value=SN} 在表達(dá)式生成的上下文中寫入key為ownerKey值為SN的參數(shù)
- “-“ 表示靜態(tài)表達(dá)式“-”
- @{bean, ref=sequence} 指定引用id為sequence的spring bean來(lái)生成表達(dá)式的一部分
- “-“表示靜態(tài)表達(dá)式”-“
- @{com.jd.coo.sa.sn.expression.CheckSumExpression} 表示要?jiǎng)?chuàng)建指定類com.jd.coo.sa.sn.expression.CheckSumExpression的實(shí)例來(lái)生成表達(dá)式的一部分
該bean的interpreter屬性指定了表達(dá)式的解釋器,該解釋器會(huì)將表達(dá)式值轉(zhuǎn)換為實(shí)現(xiàn)了Expression接口的對(duì)象,通過(guò)該對(duì)象可以計(jì)算出單號(hào)的值。
表達(dá)式解釋器查找表達(dá)式中的“@{”和“}”對(duì),將其內(nèi)部的表達(dá)式解析為動(dòng)態(tài)表達(dá)式,將其他部分的表達(dá)式解析為靜態(tài)表達(dá)式。動(dòng)態(tài)表達(dá)式分為三種類型:
- spring配置文件中的bean引用表達(dá)式
- 指定上下文參數(shù)的表達(dá)式
- 指定自定義類型的表達(dá)式
第3種表達(dá)式留出任意擴(kuò)展自定義表達(dá)式的擴(kuò)展點(diǎn)。
Expression接口定義如下:
- import com.jd.coo.sa.sn.GenContext;
- /**
- * SmartSNGen表達(dá)式接口
- *
- * Created by zhaoyukai on 2016/10/18.
- */
- public interface Expression {
- /**
- * 計(jì)算表達(dá)式的值
- * @param context 表達(dá)式計(jì)算上下文, 表達(dá)式可以根據(jù)需要將計(jì)算中間值存儲(chǔ)到上下文中, 以便在表達(dá)式之間共享數(shù)據(jù)
- * @return 表達(dá)式計(jì)算值
- */
- Object eval(GenContext context);
- /**
- * 計(jì)算優(yōu)先級(jí), 優(yōu)先級(jí)越高越先執(zhí)行, 如果表達(dá)式需要依賴其他表達(dá)式的值, 則要在依賴表達(dá)式計(jì)算之后執(zhí)行
- * @return 執(zhí)行順序
- */
- ExecuteOrder executeOrder();
- /**
- * 該表達(dá)式的最大字符串長(zhǎng)度值
- *
- * @return 最大長(zhǎng)度值
- */
- int maxLength();
- }
通過(guò)實(shí)現(xiàn)此接口即可實(shí)現(xiàn)任何自定義的單號(hào)生成邏輯。如下是自定義的單號(hào)校驗(yàn)位生成表達(dá)式示例:
- public class CheckSumExpressionimplements Expression {
- public Object eval(GenContext context) {
- Long newId = (Long) context.get("sequence");
- if (newId == null) {
- throw newRuntimeException("sequence can not be null when calculate checksum");
- }
- return newId * 9 % 31 % 10;
- }
- public ExecuteOrder executeOrder() {
- return ExecuteOrder.AfterNormal;
- }
- public int maxLength() {
- return 1;
- }
- }
總結(jié)
本文提到了多種單號(hào)數(shù)字序列生成方式,還介紹了高可用、高性能以及擴(kuò)展性的實(shí)現(xiàn)方式。
- 要根據(jù)場(chǎng)景, 并發(fā)量, 單號(hào)類型數(shù)量選擇數(shù)字序列生成方式;
- 不要裸奔, 要使用高可用+高性能序列生成器, 保證單號(hào)生成方式的可用性和性能;
- 底層序列要從物理上做隔離, 否則出現(xiàn)硬件故障高可用機(jī)制也會(huì)時(shí)效;
- 使用了多個(gè)底層序列生成方式時(shí)生成的序列是大致自增, 不能保證完全自增, 這是設(shè)計(jì)使然, 如果要保證完全自增, 則會(huì)出現(xiàn)單點(diǎn), 在完全自增和單點(diǎn)的選擇上, 我們選擇了大致自增+非單點(diǎn);
- 高性能序列生成的性能可以通過(guò)調(diào)節(jié)其memoryBitLength屬性來(lái)提高, 但要根據(jù)業(yè)務(wù)實(shí)際情況來(lái)做選擇,memoryBitLength屬性值越高在內(nèi)存生成的序列數(shù)越多,性能越高, 但在進(jìn)程停止時(shí)丟失的序列也會(huì)越多。
作者:趙玉開(kāi),十年以上互聯(lián)網(wǎng)研發(fā)經(jīng)驗(yàn),2013年加入京東,在運(yùn)營(yíng)研發(fā)部任架構(gòu)師,期間先后主持了物流系統(tǒng)自動(dòng)化運(yùn)維平臺(tái)、青龍數(shù)據(jù)監(jiān)控系統(tǒng)和物流開(kāi)放平臺(tái)的研發(fā)工作,具有豐富的物流系統(tǒng)業(yè)務(wù)和架構(gòu)經(jīng)驗(yàn)。在此之前在和訊網(wǎng)負(fù)責(zé)股票基金行情系統(tǒng)的研發(fā)工作,具備高并發(fā)、高可用互聯(lián)網(wǎng)應(yīng)用研發(fā)經(jīng)驗(yàn)。
【本文來(lái)自51CTO專欄作者張開(kāi)濤的微信公眾號(hào)(開(kāi)濤的博客),公眾號(hào)id: kaitao-1234567】