妹紙小A的計數工作

文中所述事情均是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是均勻的,分桶也就是均勻的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章