項目描述
最近做的一個項目有這么一個需求:需要生成一個唯一的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" +
"then\n" +
" redis.call('set',key,ARGV[2])\n" +
" return incr\n" +
"else\n" +
" return incr\n" +
"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