淺談訂單號生成的設計方案
今天討論分享下訂單號生成的簡單實現方案,為實際場景中需要用到訂單號生成服務提供解決思路。
最簡單的方式
基于數據庫 auto_increment_increment 來獲取 ID。首先在數據庫中創建一張 sequence 表,其中 seq_name 用以區分不同業務標識,從而實現支持多種業務場景下的自增 ID,current_value 為當前值,_increment 為步長,可支持分布式數據庫的哈希策略。
- CREATE TABLE `sequence` (
- `seq_name` varchar(200) NOT NULL,
- `current_value` bigint(20) NOT NULL,
- `_increment` int(4) NOT NULL,
- PRIMARY KEY (`seq_name`)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8
通過 SELECT LAST_INSERT_ID() 方法,更新 sequence 表,進行 ID 遞增,并同時獲取上次更新的值。這里注意,current_value = LAST_INSERT_ID(current_value + _increment) 將更新的 ID 賦值給了 LAST_INSERT_ID,否則返回的將是行 id。
- <insert timeout="30" id="update" parameterType="Seq">
- UPDATE sequence
- SET
- current_value = LAST_INSERT_ID(current_value + _increment)
- WHERE
- seq_name = #{seqName}
- <selectKey resultType="long" keyProperty="id" order="AFTER">
- <![CDATA[SELECT LAST_INSERT_ID() ]]>
- </selectKey>
- </insert>
最后 Dao 提供服務,需要提醒的是注意數據庫的事務隔離級別,如果將 getSeq() 方法放到 Service 中有事務的方法里,將出現問題,因為數據庫事務開啟會創建一張視圖,在事務沒有提交之前,更新的 ID 還沒有被提交到數據庫中,這在多線程并發操作的情況下,如果事務里的其他方法導致性能慢了,可能出現兩個請求獲取到相同的 ID,所以解決方法一是不要將 getSeq() 方法放到有事務的方法里,另一種就是將 getSeq() 方法的隔離界別為 PROPAGATION_REQUIRES_NEW,實現開啟新事務,外層事務不會影響內部事務的提交。
- @Autowired
- private SeqDao seqDao;
- @Autowired
- private PlatformTransactionManager transactionManager;
- @Override
- public long getSeq(final String seqName) throws Exception {
- TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
- // 事務行為,獨立于外部事物獨立運行
- transactionTemplate
- .setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
- return (Long) transactionTemplate.execute(new TransactionCallback() {
- public Object doInTransaction(TransactionStatus status) {
- try {
- Seq seq = new Seq();
- seq.setSeqName(seqName);
- if (seqDao.update(seq) == 0) {
- throw new RuntimeException("seq update failure.");
- }
- return seq.getId();
- } catch (Exception e) {
- throw new RuntimeException("seq update error.");
- }
- }
- });
- }
稍復雜一點的方法
上述的方法的問題,想必大家都知道,就是每次獲取 ID 都要調用數據庫,在高并發的情況下會對數據庫產生極大的壓力,我們的改進方法也很簡單,就是一次申請一個段的 ID,然后發到內存里,每次獲取 ID 先從內存里取,當內存中的 ID 段全部被獲取完畢,則再一次調用數據庫重新申請一個新的 ID 段。同樣有數據庫表的設計,通過 Name 區分業務,用 ID 標明已經申請到的最大值。當然如果是分布式架構,也可以通過增加步長屬性來實現。
- CREATE TABLE `sequence_value` (
- `Name` varbinary(50) DEFAULT NULL,
- `ID` int(11) DEFAULT NULL
- ) ENGINE = InnoDB DEFAULT CHARSET = utf8
Step 是 ID 段的內存對象,有兩個屬性,其中 currentValue 當前的使用到的值,endValue 是內存申請的最大值。
- class Step {
- private long currentValue;
- private long endValue;
- Step(long currentValue, long endValue) {
- this.currentValue = currentValue;
- this.endValue = endValue;
- }
- public void setCurrentValue(long currentValue) {
- this.currentValue = currentValue;
- }
- public void setEndValue(long endValue) {
- this.endValue = endValue;
- }
- public long incrementAndGet() {
- return ++currentValue;
- }
- }
代碼的實現稍微復雜一點,獲取 ID 會根據業務標識 sequencename,先從內存獲取 Step 的 ID 段,如果為 null,則從數據庫中讀取當前最新的值,并根據步長計算 Step,然后返回請求 ID。如果從內存中直接獲取到 Step,則直接取 ID,并對 currentValue 進行加一。當 currentValue 的值超過 endValue 時,則更新數據庫的 ID,重新計算 Step。
- private Map<String,Step> stepMap = new HashMap<String, Step>();
- public synchronized long get(String sequenceName) {
- Step step = stepMap.get(sequenceName);
- if(step ==null) {
- step = new Step(startValue,startValue+blockSize);
- stepMap.put(sequenceName, step);
- } else {
- if (step.currentValue < step.endValue) {
- return step.incrementAndGet();
- }
- }
- if (getNextBlock(sequenceName,step)) {
- return step.incrementAndGet();
- }
- throw new RuntimeException("No more value.");
- }
- private boolean getNextBlock(String sequenceName, Step step) {
- // "select id from sequence_value where name = ?";
- Long value = getPersistenceValue(sequenceName);
- if (value == null) {
- try {
- // insert into sequence_value (id,name) values (?,?)
- value = newPersistenceValue(sequenceName);
- } catch (Exception e) {
- value = getPersistenceValue(sequenceName);
- }
- }
- // update sequence_value set id = ? where name = ? and id = ?
- boolean b = saveValue(value,sequenceName) == 1;
- if (b) {
- step.setCurrentValue(value);
- step.setEndValue(value+blockSize);
- }
- return b;
- }
使用該方法獲取 ID 可以減少對數據庫的訪問量,以降低數據庫的壓力,但是同樣需要注意,獲取 ID 同樣關注數據庫事務問題,因為當系統重啟的時候,stepMap 為 null,所以會取數據庫查詢當前 ID,更計算更新 Step,然后更新數據庫的 ID。如果該方法被放到數據庫事務里,由于其他方法性能慢了,導致查詢之后沒有及時更新,并發情況下另一個線程查詢的時候,可能會獲取到該線程未提交的 ID,因而出現兩個線程獲取到相同的 ID 問題。
本文小結
訂單號生成是一個非常簡單的功能,但是在高并發的場景下,高性能和高可用就成為了需要關注的要點。所以,實際工作中的每一個小細節都值得我們去深思。
【本文是51CTO專欄作者張開濤的原創文章,作者微信公眾號:開濤的博客,id:kaitao-1234567】