高可用高性能可擴展的單號生成方案
在業務開發中經常會遇到各種單號生成, 例如快遞單號、服務單號、訂單號等等。 這些單號生成往往是業務邏輯處理的第一步, 單號生成出問題,必然導致業務走不下去;另外有多少業務量就會至少有多少的單號生成需求。所以單號生成必須高可用,必須高性能。 另外業務不同需要的單號規則可能也不相同, 所以單號服務還必須具備足夠的擴展性。
一、單號定義
在進入正題之前我們先給單號下個定義, 看幾個常見的單號形式。
單號是一個數字和字符組成的序列, 它要滿足兩個條件: 一個是唯一, 保證唯一才可以作為業務標識; 另一個是符合業務需要的規則。 例如下面三個單號:
- 2017030400001 這個單號由兩個部分序列號日期20170304+定長5位補0數字00001。
- 010-6541-00001 此單號分三部分, 中間用減號連接, 第一部分為區號, 第二部分為作業單位號碼, 第三部分為作業單位產生作業的序號。
- QJ000001 則是由字符QJ開頭后面跟隨數字序列的單號。
二、單號數字序列部分的生成
上述單號定義中的數字部分通常是一個自增的數字序列。 我們可以通過數據庫的自增列、 數據庫的列+1方式、 redis或者memcached的INCR指令來生成這種數字的序列。 這四種方式都可以生成序列, 但各自有各自的好處。
1. 數據庫自增列的方式
是通過數據庫的內部機制生成的, 在普通PC上每秒約可以生成4000個數字序列, 它的好處是每一個數字序列都會保留一條記錄, 記錄生成使用時間, 缺點是吞吐量一般, 會占用一定的數據庫資源, 如下是一種推薦的表結構:
- 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類型的自增長字段,作為數字序列的值, generate_time時間戳字段可以記錄每一個單號的生成時間。生成數字序列的方式用sql說明如下:
- begin trans;
- insert into `xx_code_sequence`(generate_time)values(current_timestamp);
- select last_insert_id();
- 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操作, 來得到唯一的數字序列, 是通過數據庫的行鎖來保障唯一的, 因為涉及到行鎖, 所以這種方式生成序列的單行吞吐量不會太大, 適合需要生成多種(每一種放到一行)不同數字生成需求。 如下是一種推薦的表結構:
- create table `xx_rowbased_sequence` (
- `owner_key` varchar(32) NOT NULL,
- `current_value` bigint NOT NULL,
- PRIMARY KEY (`owner_key`)
- );
表中的ownerKey列為單號種類標識, current_value為+1操作列。生成序列的方式用sql說明如下
- 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;
需要注意使用此方式生成數字序列事務隔離級別需要是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時間后,也會被重新放入可用列表中。
如果一個底層序列被標記為不可用, 過配置時間后會將其恢復到可用列表中, 自動恢復機制可以避免底層序列生成器已恢復可用, 而程序卻一直不使用此底層序列生成器的情況。
高可用實現的內部結構圖, 如下圖所示:
其核心方法如下所示:
- 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;
- }
使用時配置bean使用即可, 如下spring bean xml配置:
- <bean id="highAvailableSequenceGen" class="com.jd.coo.sa.sequence.ha.BitwiseLoadBalanceSequenceGen">
- <!-- 指定高可用序列底層序列生成序列后向左移位位數-->
- <constructor-arg index="0" value="2"/>
- <!-- 指定底層序列 -->
- <constructor-arg index="1">
- <map>
- <!-- key 為底層序列生成值左移位后或的下標-->
- <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判斷的閾值設置為一個很大的值, 避免timeout應用error的情況發生-->
- <property name="timeoutThresholdInMilliseconds" value="200"/>
- <!-- 超時多少次后會移出可用列表 -->
- <property name="timeoutEventCountThreshold" value="3"/>
- <!-- 計算超時異常的時間周期, 以秒為單位 -->
- <property name="timeoutTimeThresholdInSeconds" value="60" />
- <!-- 移到不可用隊列多長時間后會被重新放入可用隊列 -->
- <property name="onErrorRescueThresholdInSeconds" value="2000"/>
- </bean>
四、高性能實現
單號生成只是業務操作的第一個步驟, 業務操作往往是復雜耗時的, 我們必須保證單號生成的性能, 使其幾乎不會影響業務時間。
上述介紹的四種序列生成方式都是跨網絡通過中間件獲得的序列號,要進一步優化其性能,我們需要將序列放在離CPU更近的地方――內存中。我們使用如下兩種方式將數字序列放到CPU更近的地方:
- 將內部序列值向左移位n位, 然后序列的最右n位在內存生成,一次生成2的n次方個數字序列, 然后放在內存隊列中;
- 異步提前生成:實時計算序列號方法被調用的速度, 然后在異步線程(池)中生成最近x ms需要的序列,放入內存隊列中備用
這兩種方式并不一定都需要, 置放入內存隊列中的數字序列越多,重啟時丟失的也會越多。
其內部結構圖示如下:
高性能序列使用的bean配置如下:
- <bean class="com.jd.coo.sa.sequence.QueuedSequenceGen" id="queuedSequenceGen" init-method="start" destroy-method="stop">
- <!-- 指定內部序列, 通常是一個高可用的內部序列-->
- <constructor-arg index="0" ref="haSequenceGen" />
- <!-- 指定內存中生成的bit位數-->
- <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>
- <!--結束異步配置-->
- </bean>
通過設定memoryBitLength,指定序列的最右的memoryBitLength位在內存中生成以提高生成的效率。 需要注意memoryBitLength值越大則在內存中的序列條數越多, 性能越高, 如果發生重啟時丟失的序列也會越多, 要根據情況來設置。 支持異步生成序列值, 異步生成的速度會根據序列值消費速度自適應。
五、關于可擴展性
單號規則多種多樣, 不能每增加一種規則就增加一個需求, 我們需要相對靈活的擴展性。 上述介紹了多種單號數字序列的生成方式, 和數字序列生成的高可用和高性能實現, 他們都實現了同一個接口:
- /**
- * 根據序列業務類型生成新序列的接口
- *
- * 生成序列是大致遞增的
- *
- * Created by zhaoyukai on 2016/8/8.
- */
- public interface SequenceGen {
- /**
- * 生成序列
- * @param ownerKey 序列業務key
- * @return 新序列值
- */
- long gen(String ownerKey);
- }
有了這個統一的數字序列生成接口, 我們可以擴展多種不同的數字序列生成方式。 或者實現不同的高可用、高性能機制。
另外在本文的開頭我們介紹了多種不同的單號生成規則, 要靈活滿足這些不同的規則, 我們使用表達式來表達單號的組合規則, 通過將表達式解析成不同的Expression來實現不同單號部分的生成。 下面我們看一個單號表達式的示例, 如下是一個spring bean配置:
- <!-- 單號生成bean, 在應用中注入此bean生成單號 -->
- <bean class="com.jd.coo.sa.sn.SmartSNGen" name="snGen">
- <!-- 序列號的表達式, 見下面說明 -->
- <constructor-arg value="@{ownerKey, value=SN}-@{bean, ref=sequence}-@{com.jd.coo.sa.sn.expression.CheckSumExpression}"/>
- <!-- 表達式解析器 -->
- <property name="interpreter">
- <!-- 單號生成器的表達式解釋器, 固定為SmartInterpreter-->
- <bean class="com.jd.coo.sa.sn.expression.SmartInterpreter" name="smartInterpreter"/>
- </property>
- </bean>
SmartSNGen類負責根據表達式生成不同規則的單號,其構造函數第一個參數值:
- @{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接口的對象,通過該對象可以計算出單號的值。
表達式解釋器查找表達式中的“@{”和“}”對,將其內部的表達式解析為動態表達式,將其他部分的表達式解析為靜態表達式。動態表達式分為三種類型:
- spring配置文件中的bean引用表達式
- 指定上下文參數的表達式
- 指定自定義類型的表達式
第3種表達式留出任意擴展自定義表達式的擴展點。
Expression接口定義如下:
- import com.jd.coo.sa.sn.GenContext;
- /**
- * SmartSNGen表達式接口
- *
- * Created by zhaoyukai on 2016/10/18.
- */
- public interface Expression {
- /**
- * 計算表達式的值
- * @param context 表達式計算上下文, 表達式可以根據需要將計算中間值存儲到上下文中, 以便在表達式之間共享數據
- * @return 表達式計算值
- */
- Object eval(GenContext context);
- /**
- * 計算優先級, 優先級越高越先執行, 如果表達式需要依賴其他表達式的值, 則要在依賴表達式計算之后執行
- * @return 執行順序
- */
- ExecuteOrder executeOrder();
- /**
- * 該表達式的最大字符串長度值
- *
- * @return 最大長度值
- */
- int maxLength();
- }
通過實現此接口即可實現任何自定義的單號生成邏輯。如下是自定義的單號校驗位生成表達式示例:
- 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;
- }
- }
總結
本文提到了多種單號數字序列生成方式,還介紹了高可用、高性能以及擴展性的實現方式。
- 要根據場景, 并發量, 單號類型數量選擇數字序列生成方式;
- 不要裸奔, 要使用高可用+高性能序列生成器, 保證單號生成方式的可用性和性能;
- 底層序列要從物理上做隔離, 否則出現硬件故障高可用機制也會時效;
- 使用了多個底層序列生成方式時生成的序列是大致自增, 不能保證完全自增, 這是設計使然, 如果要保證完全自增, 則會出現單點, 在完全自增和單點的選擇上, 我們選擇了大致自增+非單點;
- 高性能序列生成的性能可以通過調節其memoryBitLength屬性來提高, 但要根據業務實際情況來做選擇,memoryBitLength屬性值越高在內存生成的序列數越多,性能越高, 但在進程停止時丟失的序列也會越多。
作者:趙玉開,十年以上互聯網研發經驗,2013年加入京東,在運營研發部任架構師,期間先后主持了物流系統自動化運維平臺、青龍數據監控系統和物流開放平臺的研發工作,具有豐富的物流系統業務和架構經驗。在此之前在和訊網負責股票基金行情系統的研發工作,具備高并發、高可用互聯網應用研發經驗。
【本文來自51CTO專欄作者張開濤的微信公眾號(開濤的博客),公眾號id: kaitao-1234567】