採用redis生成唯一且隨機的訂單號
- 2019 年 10 月 3 日
- 筆記
項目描述
最近做的一個項目有這麼一個需求:需要生成一個唯一的11位的就餐碼(類似於訂單號的概念),就餐碼的規則是:一共是11位的數字,前面6位是日期比如2019年07月20就是190720,後面五位是隨機數且不能是自增的,不然容易讓人看出一天的單量。
解決方案
五位隨機數不能用隨機生成的,不然可能不唯一,所以想到了預生成的方案:
採用redis
- 隨機數生成
先生成10000~99999共9萬個數(從1萬開始是懶得再前面補0了),然後打亂分別 存入redis的list數據結構 90個key每個key存1000個數。取的時候通過LINDEX進行讀取。
List<String> numList=new ArrayList<>(); //90萬個數 每個redis key 1000個數,要存90個key. for (int i=10000;i<=99999;i++){ numList.add(String.valueOf(i)); } //打亂順序 Collections.shuffle(numList); //生成key for (int j=10;j<=99;j++){ String redisKey="qrcode:"+j; List<String> newList= test.subList((j-10)*1000,(j-10)*1000 + 1000); jedisCluster.rpush(redisKey,newList.toArray(new String[newList.size()])); }
這樣每個key的index值就是0~999,key就是qrcode:10/qrcode:11/qrcode:12…/qrcode:99.
- 計數key
再使用一個key來計數每次生成一個就餐碼就加1,值也從10000開始,計數的前兩位用來表示該取哪個key,後三位代表key的索引。比如現在計數記到12151那就是取上面生成的qrcode:12 key里索引為151的value,然後當計數到99999時再從10000重新計數,這樣保證一天有9萬個隨機數可以使用且不會取到相同的隨機數。這樣可以解決一天最多9萬單數量級的業務,後面一天百萬級同理可以擴充成6位7位等。
先初始化:
jedisCluster.set(qrcode:incr,9999);
示例
public String getOneQrCode() { Long incr = jedisCluster.incr("qrcode:incr"); //測試環境生成到19999 int maxIncr=19999 //int maxIncr = 99999; //後期單量過猛時需要考慮--並發風險導致的就餐碼重複 todo if (incr == maxIncr) { jedisCluster.set("qrcode:incr", String.valueOf(10000)); } System.out.println("incr:"+incr); //取前兩位 String key = incr.toString().substring(0, 2); //取後三位作為list里的index Integer index = NumberUtil.getIntValue(incr.toString().substring(2)); //獲得5位隨機數 String qrcode = jedisCluster.lIndex("qrcode:"+ key, index); return qrcode; }
並發風險
當計數到最大值時,需要重置計數key(qrcode:incr)為10000會有執行緒不安全的問題。
我們先編寫一個並發方法單元測試一下:
測試環境由於只生成10000個隨機數,maxincr=19999,所以
我們先把計數的key設置成接近maxincr來進行並發測試,設置成19997後獲取2個qrcode將進行重置成10000.
jedisCluster.set(qrcode:incr,19997);
開啟5個執行緒並發測試:
private static final int threadNum=5; //倒計數器,用於模擬高並發 private CountDownLatch countDownLatch=new CountDownLatch(threadNum); @Test public void benchmark() { Thread[] threads=new Thread[threadNum]; for (int i = 0; i <threadNum ; i++) { final int j=i; Thread thread=new Thread(new Runnable() { @Override public void run() { try { countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("qrcode"+getOneQrCode()); } }); threads[i]=thread; thread.start(); countDownLatch.countDown(); } for (Thread thread :threads) { try { thread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } }
5個執行緒並發測試的結果:
- 對qrcode:incr進行get返回的結果是10000.
- 獲取的結果為:
由於並發導致5個執行緒都先執行到
Long incr = jedisCluster.incr("qrcode:incr");
最終incr的值分別為19998/19999/20000/20001/20002.所以後面三個計數的key為20,由於測試環境只生成到了qrcode:19,所以返回的是null。
解決
所以判斷到達maxincr並重置成10000時應該是原子操作。所以這裡採用lua腳本的方式執行。
Redis使用lua腳本
版本:自2.6.0起可用。
時間複雜度:取決於執行的腳本。
使用Lua腳本的好處:
- 減少網路開銷。可以將多個請求通過腳本的形式一次發送,減少網路時延。
原子操作。redis會將整個腳本作為一個整體執行,中間不會被其他命令插入。因此在編寫腳本的過程中無需擔心會出現競態條件,無需使用事務。 - 復用。客戶端發送的腳本會永久存在redis中,這樣,其他客戶端可以復用這一腳本而不需要使用程式碼完成相同的邏輯。
- redis會將整個腳本作為一個整體執行,中間不會被其他命令插入。因此在編寫腳本的過程中無需擔心會出現競態條件,無需使用事務。
所以對獲取qrcode進行改造:
public String getOneQrcodeLua(){ String lua="local key = KEYS[1]n" + "local incr=redis.call('incr',key) n"+ "if incr == tonumber(ARGV[1]) n" + "thenn" + " redis.call('set',key,ARGV[2])n" + " return incrn" + "elsen" + " return incrn" + "end"; List<String> keys = new ArrayList<>(); keys.add("qrcode:incr"); List<String> argv = new ArrayList<>(); argv.add("19999"); argv.add("10000"); Object o= jedisCluster.eval(lua,keys,argv); // System.out.println("incr"+o); //取前兩位 String key = o.toString().substring(0, 2); //取後三位作為list里的index Integer index = NumberUtil.getIntValue(o.toString().substring(2)); //獲得5位隨機數 String qrcode = jedisCluster.lIndex("qrcode:"+ key, index); return qrcode; }
5個執行緒並發測試的結果:
- 對qrcode:incr進行get返回的結果是10003.
- 獲取的結果為:
一切正常。
參考
https://redisbook.readthedocs.io/en/latest/feature/scripting.html
http://doc.redisfans.com/script/eval.html