文中所述事情均是YY。
小A是一個呆萌的妹紙,最近剛剛加入小B的團隊,這幾天小B交給她一個任務,讓她每天統計一下團隊裏九點半之前到公司的人數。
九點半之前到公司人數
於是,每天早上小A都早早來到公司,然後拿一個本子來記,來一個人就記一下。 [1]
這裏,其實小A的做法和下面的代碼一樣:
public class SimpleCounter1 {
List<CheckRecordDO> counter = new LinkedList<CheckRecordDO>();
public void check(long id) {
CheckRecordDO checkRecordDO = new CheckRecordDO();
checkRecordDO.setId(id);
counter.add(checkRecordDO);
}
public int count() {
return counter.size();
}
}
每當小B問有多少人已經來了的時候,小A只要瞅一眼本子上記錄的人數就能立馬回答了。
過了幾天,小A發現,同學們上班的時候不是都一個一個來的,有的時候一下子同時來了好幾個人,就會有漏記下的,該怎麼解決這個問題呢?
小A想了一個辦法,她讓來的同學們一個一個等她記下來了,再到自己的位子上去。這麼做以後再也沒有出現過漏記的情況了。[2]
小A的這個辦法就是加了一個鎖,只能一個個串行的來:
public class SimpleCounter2 {
final List<CheckRecordDO> counter = new LinkedList<CheckRecordDO>();
public void check(long id) {
synchronized (counter) {
CheckRecordDO checkRecordDO = new CheckRecordDO();
checkRecordDO.setId(id);
counter.add(checkRecordDO);
}
}
public int count() {
return counter.size();
}
}
可是好景不長,開始幾天同學們還能接受小A的做法,時間長了,很多同學就有意見,同學們都不想花時間在等記名字上面。
小A只得改變一下方法,她在每個入口處都放置了一個盒子,讓同學來了後自己把名字寫在小紙片上,然後放到盒子裏,小A數一下盒子裏的小紙片數量就能知道來了多少人。[3]
這種做法類似於在數據庫裏插入幾條記錄,統計的時候count一下:
public class SimpleCounter3 {
private CheckRecordDAO checkRecordDAO;
public void check(long id) {
CheckRecordDO checkRecordDO = new CheckRecordDO();
checkRecordDO.setId(id);
do {
if (checkRecordDAO.insert(checkRecordDO)) {
break;
}
} while(true);
}
public int count() {
return checkRecordDAO.count();
}
}
雖然有時候一起來的時候人很多,但只需要增加一下盒子的數量,也不會產生擁堵的情況了,小B對小A的方案很滿意。
小A使用盒子的思路,就相當於建立分庫分表機制,增大並行數量,解決擁堵。
由於小A的計數工作完成的非常出色,於是,其他團隊的計數工作也都移交到小A這邊了。呆萌的小A原本只需要統計二十幾號人,現在一下子增加到了幾百號人。小A每次數盒子裏的小紙片數量都需要花費比較長的時間,頓時,呆萌的妹紙又陷入了淡淡的憂傷當中。
這時候旁邊的小C站了出來,對小A說,其實小B並不關心到底來了哪些人,只需要知道來了多少人就可以了。
小A一下子明白過來,立馬改進了方法,在每個入口設置了一個號碼本,每一個同學來的時候撕下一個號碼,小A只需要把幾個入口的號碼本上待撕的數字加一下就能得到總數了。[4]
public class ParallelCounter1 {
final int ENTRY_COUNT = 5;
Counter[] counter;
{
counter = new Counter[ENTRY_COUNT];
for (int i = 0; i < ENTRY_COUNT; i++) {
Counter c = new Counter();
c.value = 0;
counter[i] = c;
}
}
public void check(int id, int entry) {
synchronized (counter[entry]) {
counter[entry].value++;
}
}
public int count() {
int total = 0;
for (int i = 0; i < ENTRY_COUNT; i++) {
total += counter[i].value;
}
return total;
}
public class Counter {
public int value;
}
}
不幸的是,問題還是來了,由於每個入口進來的人數不一致,有些入口的號碼本很容易早早用完,另外一些入口卻還剩下不少。
小C是一個熱心的man,這時候又站出來了,他說,既然各個入口的人數不一樣,那麼按照人數比例設置號碼本數量不就可以了麼。於是小A在各個入口處設置了不同數量的號碼本,果然問題解決了。[5]
現在小A的做法和下面的實現一樣,每一個entry有不同數量的counter,每個員工check的時候隨機選擇一個counter:
public class ParallelCounter2 {
final int COUNTS = 16;
final int ENTRY_COUNT = 5;
Counter[] counter;
Integer[][] entryCounter = {
{0,0},
{1,1},
{2,3},
{4,7},
{8,15}
};
{
counter = new Counter[COUNTS];
for (int i = 0; i < COUNTS; i++) {
Counter c = new Counter();
c.value = 0;
counter[i] = c;
}
}
public void check(int id, int entry) {
int idx = choose(entry);
synchronized (counter[idx]) {
counter[idx].value++;
}
}
private int choose(int entry) { // 隨機選擇入口處的一個號碼本
int low = entryCounter[entry][0];
int high = entryCounter[entry][1];
return low + (int)Math.floor(Math.random() * (high - low + 1));
}
public int count() {
int total = 0;
for (int i = 0; i < COUNTS; i++) {
total += counter[i].value;
}
return total;
}
public class Counter {
public int value;
}
}
按代碼所示:
總共有5個入口,使用了16個號碼本。
經過這樣調整之後,即使有時候有入口因爲施工或其他原因臨時關閉,也只需要調整一下每個入口的號碼本數量就可以了。
前20人送咖啡券
經過一段時間的統計,小B發現,大多數時候九點半前到公司的人數都不超過20個人。怎麼才能讓大家早點來公司呢?小B想了一個辦法,每天前20個來公司的人送咖啡券。
小A想,要給前20個人發咖啡券,那隻要記下每個人來的時間,給最早的前20個人發就可以了。可以用之前放置盒子的方法,讓每個來的人寫下自己的名字和來的時間(價值觀保證寫的時間是真實的,-_-),最後按時間統計出前20名發咖啡券就可以了。[6]
代碼描述相比之前也只是有很小的改動:
public class SimpleCounter4 {
private CheckRecordDAO checkRecordDAO = new CheckRecordDAO();
public void check(long id) {
CheckRecordDO checkRecordDO = new CheckRecordDO();
checkRecordDO.setId(id);
checkRecordDO.setTime(new Date());
do {
if (checkRecordDAO.insert(checkRecordDO)) {
break;
}
} while(true);
}
public int count() {
return checkRecordDAO.count();
}
public void give() {
checkRecordDAO.updateStatusWithLimit(1,20);
}
}
小B覺得,事後發放咖啡券不如即時發放效果好,讓小A在同學們來的時候就發。小A一下子又陷入了淡淡憂傷當中。如果只有一個入口的話,可以把咖啡券和號碼本放在一起,讓同學們來的時候自己拿一張,而現在有好幾個入口,每個入口來的人數都不固定,不管怎麼分,都可能會造成一個入口已經沒得發了,另外的入口還有。
想來想去,小A還是沒有想到什麼好辦法,難道要回到最初,一個一個來登記然後發券?
小A重新梳理了一下發咖啡券的需求,發券的方式要麼一個一個發,要麼不一個一個發。肯定不要用之前串行的辦法,還是得往同時發的方面考慮。按照之前的思路,在幾個入口同時都放,將20張咖啡券分配到每個號碼本,撕下一個號碼的時候拿一張咖啡券。如果一個號碼本對應的咖啡券已經被領完了,就從別的地方調咖啡券過來。如果所有的咖啡券已經發完了,那麼就設置一個標誌,後來的人都沒有咖啡券可以領了。[7]
public class ParallelCounterWithCallback3 {
final int TOTAL_COFFEE_COUPON = 20;
final int COUNTS = 16;
final int ENTRY_COUNT = 5;
Counter[] counter;
boolean noMore = false;
final Integer[] coffeeCoupon = new Integer[COUNTS];
final Integer[][] entryCounter = {
{0,2},
{3,8},
{9,10},
{11,12},
{13,15}
};
{
counter = new Counter[COUNTS];
for (int i = 0; i < COUNTS; i++) {
Counter c = new Counter();
c.value = 0;
counter[i] = c;
coffeeCoupon[i] = (TOTAL_COFFEE_COUPON / COUNTS); // 平分
if (i < TOTAL_COFFEE_COUPON % COUNTS) {
coffeeCoupon[i] += 1;
}
}
}
public void check(int id, int entry, Callback cbk) {
int idx = choose(entry), get = 0;
synchronized (counter[idx]) {
if (coffeeCoupon[idx] > 0) {
get = 1;
coffeeCoupon[idx]--;
counter[idx].value++;
} else {
if (!noMore) { // 其他地方還有咖啡券
for (int i = 0; i < COUNTS && get == 0; i++) {
if (idx != i && coffeeCoupon[i] > 0) { // 找到有券的地方
synchronized (counter[i]) {
if (coffeeCoupon[i] > 0) {
get = 1;
coffeeCoupon[i]--;
counter[idx].value++;
}
}
}
}
if (get == 0) noMore = true;
}
if (noMore) counter[idx].value++;
}
}
cbk.event(id, get);
}
private int choose(int entry) { // 隨機選擇入口處的一個號碼本
int low = entryCounter[entry][0];
int high = entryCounter[entry][1];
return low + (int)Math.floor(Math.random() * (high - low + 1));
}
public int count() {
int total = 0;
for (int i = 0; i < COUNTS; i++) {
total += counter[i].value;
}
return total;
}
public class Counter {
public int value;
}
public interface Callback {
int event(int id, int get);
}
}
發放咖啡券必須得是先到先得,如果用Pim表示取第i個號碼本上號碼m的人撕下號碼的時間,Cim表示其是否取得咖啡券(1代表獲得,0代表未獲得),那麼先到先得可以這麼來表述:
∀m > n → Pim > Pin,
∃ m > n, Cim = 1 → Cin = 1
上面的代碼服從這兩條約束。
咖啡券發了一段時間後,同學們來公司的時間都比以前早了,各個地方的咖啡券基本上都在同一時間發完,根本就不存在從別的地方調咖啡券的情況。[8]
在各個號碼本號碼消耗速率保持一致的情況下,小A所需要做的事情也得到了簡化,只要平分咖啡券到每個號碼本就行了,甚至各個號碼本分到的咖啡券數量都不需要預先分配,對應的代碼如下:
public class ParallelCounterWithCallback4 {
final int TOTAL_COFFEE_COUPON = 20;
final int COUNTS = 16;
final int ENTRY_COUNT = 5;
Counter[] counter;
final Integer[][] entryCounter = {
{0,2},
{3,8},
{9,10},
{11,12},
{13,15}
};
{
counter = new Counter[COUNTS];
for (int i = 0; i < COUNTS; i++) {
Counter c = new Counter();
c.value = 0;
counter[i] = c;
}
}
public void check(int id, int entry, Callback cbk) {
int idx = choose(entry), get = 0;
synchronized (counter[idx]) {
if (counter[idx].value < coupon(idx)) {
get = 1;
}
counter[idx].value++;
}
cbk.event(id, get);
}
private int coupon(int idx) {
int c = (TOTAL_COFFEE_COUPON / COUNTS); // 平分
return idx < TOTAL_COFFEE_COUPON % COUNTS ? c + 1 : c;
}
private int choose(int entry) { // 隨機選擇入口處的一個號碼本
int low = entryCounter[entry][0];
int high = entryCounter[entry][1];
return low + (int)Math.floor(Math.random() * (high - low + 1));
}
public int count() {
int total = 0;
for (int i = 0; i < COUNTS; i++) {
total += counter[i].value;
}
return total;
}
public class Counter {
public int value;
}
public interface Callback {
int event(int id, int get);
}
}
好吧,小A的工作總算告一段落。
小Z
任何事都需要按實際情況來分析處理,不好照搬。小A的最後一種方案是在項目中實際使用的,業務場景是限量開通超級粉絲卡。既然是限量, 便需要計數,便需要檢查能不能開卡 。在這個方案裏將計數和限量分成了兩步來做,計數這一步通過分多個桶來保證併發容量,只要每個桶的請求量差別不大,總的限量就可以直接平分到每一個桶的限量。這裏面,最關鍵的地方在於分桶的均勻。由於是按用戶分桶,通用做法便是按id取模分桶,由於用戶id是均勻的,分桶也就是均勻的。