緣起
其實從2014年,微信推出微信紅包,就有很多人揣測其中的算法是怎樣的,知乎上很多人分析了很多。
我一直覺得,應該就是幾個簡單的約束,然後拍腦袋一個算法,不需要特別的要求。
但還是有很多人會替開發人員做一些考慮,比如“不能讓一個人運氣太差,連續搶到0.01,這會讓用戶體驗很差”之類的要求,我覺得這是不可能的。
爲什麼?
很明顯,微信紅包這種極其高併發的請求,算法應該越簡單越好,不可能有太複雜的人性化約束。另一方面,假設紅包金額是完全隨機的,那麼一個人連續搶到0.01的概率其實是特別低的。
對於微信紅包的建模和約束,很多人已經做過很好的分析了。
問題爲:金額爲M,分成N個紅包。
約束其實就簡單的一條——
每個紅包的金額,都是精確到小數點後兩位,且至少爲0.01元。
沒圖沒真相,我寫的代碼的效果如下(10塊錢分成5個紅包):
感覺結果還算正常,偶發太高的金額,這個可以通過控制最大的金額的上限,比如有人提出,微信內部的人員告知,最大的金額是剩餘金額的平均值的兩倍。這樣是比較合理的,因爲一旦一個人搶了太高的金額,後邊的人很可能就只能在0.01左右徘徊了。
怎麼辦?
就隨機而言,很容易想到正態分佈。那麼,均值選多少?方差選多少?
我的代碼主體是下面這個函數:
它的邏輯很簡單,每次把均值設爲剩下金額的平均數,標準差設置爲均值和最小金額(0.01)的差值,爲何這樣選取的理由其實是很明顯了。
程序員的重點
我覺得算法不應該是重點,重點是如何設計一個適合收發紅包的高併發架構!
以下觀點來自於知乎上看文章(https://www.zhihu.com/question/22625187),以及自己的一些想法,不一定是好的,因爲沒實踐過。
問題1:紅包金額是事先分配好,然後逐個拿嗎?
解釋一下這個問題,意思是,比如10塊錢分成3個,做法是分成2.00,3.00,5.00,然後第一個人來的時候佔第一個位置,拿到了2.00……諸如此類。
答案是:肯定不需要啊。
如果事先分配好,假設你分配1000個紅包,豈不是得用1000倍的空間來存放?
如果放在內存裏,一臺服務器豈不是很容易爆?
所以更好的方法應該是:
1. 類似查表的,給每個目前存活的紅包(每個紅包只能存活兩天)編號,放在一張內存的表裏;
2. 表的每一行存放紅包剩餘總金額、紅包個數、當前已經被領取的個數;
爲了抵抗紅包被領完,還有大量無效的搶紅包請求進入後臺,還可以在進入後臺之前使用cache,原子遞減剩餘紅包個數,爲0的話,就馬上可以返回,不用進入後臺了。
問題2:進入後臺的搶同一個紅包的多個請求,需要一個隊列來序列化請求嗎?
知乎上的人說不需要隊列,但我覺得,如果沒有請求隊列的話,那同時有多個請求進入後臺搶同一個紅包,會發生什麼?
同時調用同個函數!
但這個分配紅包金額的函數,很明顯是不可重入的,因爲它依賴於上一次調用的結果。
雖然我沒接觸過內存數據庫,但我覺得,應該有一種類似鎖的機制來調度。比如多個線程同時想對一個紅包(在內存數據庫裏就是一行)進行修改,鎖機制會自動安排順序,所以就不需要請求隊列來強制從請求的粒度來使得搶紅包有序進行。
這種對數據庫的請求粒度,比對後臺的請求粒度更細,直觀上更加有效!
問題3:搶到紅包之後發生了什麼?
首先需要明確的一點是,錢肯定會被轉發到每個搶到紅包的人的賬戶裏。
但是,轉賬是一個很耗時的操作,所以應該馬上返回搶紅包的結果,而轉賬則是異步的操作。
所以零錢到賬會有延遲!
再者,每個人可以看到其他人搶紅包的結果(如果該紅包是羣紅包的話),這個結果是來自哪裏呢?
硬盤?
內存?
cache?
我覺得應該放到cache裏爲好,因爲只有追加(新搶到紅包的記錄),不會修改條目(搶到多少就是多少),而且查詢的人跟搶紅包的人的數量差不多。
e,還有一個手氣最佳!
這個,我覺得……在客戶端統計就可以了,哈哈哈,後臺完全沒必要做這件事。
比如你點查看搶紅包的記錄時,後臺只需要返回所有目前關於該紅包被搶的數據,總數爲多少個,每一個的所有者、金額、時間點,然後客戶端判斷是否已經搶完了(只有搶完才能判斷誰是手氣最佳),若是則找出金額最大、時間最早的一個紅包所有者,將其標記爲“手氣最佳”。
po代碼
代碼寫得略醜:
#include <stdlib.h>
#include <ctime>
#include <cmath>
#include <iostream>
#include <iomanip>
using namespace std;
class RedPacket {
public:
RedPacket(float total, int number)
: m_total(total), m_number(number), m_countOfUsed(0), goodSettings(true) {
if (number <= 0) {
cout << "【錯誤】紅包數量應該爲正整數" << endl;
goodSettings = false;
} else {
if (round2(total) != total) {
cout << "【錯誤】總金額應該只精小數點後兩位" << endl;
goodSettings = false;
} else {
float average = round2(total / number);
if (average < 0.01) {
cout << "【錯誤】總金額太少,或者紅包數量太多" << endl;
goodSettings = false;
}
}
}
}
bool nextRedPacket() {
// 紅包搶完了,或者設置出錯,則立即返回失敗
if (m_countOfUsed >= m_number || !goodSettings) {
return false;
} else {
float result;
++m_countOfUsed;
// 最後一個紅包直接拿剩下的全額
if (m_countOfUsed == m_number) {
result = m_total;
} else {
// 限定每個人至少都能拿到0.01
float minMoney = 0.01;
float maxMoney = round2(m_total - (m_number - m_countOfUsed) * minMoney);
// 如果金額沒得選,就只能這個了
if (maxMoney == minMoney) {
result = maxMoney;
} else {
// 保證紅包金額是隨機的,且滿足正態分佈:均值爲剩下金額的平均值,標準差爲平均值減去最小金額
do {
float average = m_total / (m_number - m_countOfUsed + 1);
float sigma = average - minMoney;
result = round2(GaussRand(sigma, average));
} while (result < minMoney || result > maxMoney);
}
}
m_total -= result;
cout << "第" << m_countOfUsed << "個紅包的金額爲:" << setprecision(2) << fixed << result << endl;
return true;
}
}
private:
// Box-Muller算法,產生標準正態分佈
double GaussRand() {
static double v1, v2, s;
static int phase = 0;
double x;
if (0 == phase) {
do {
double u1 = (double)rand() / RAND_MAX;
double u2 = (double)rand() / RAND_MAX;
v1 = 2 * u1 - 1;
v2 = 2 * u2 - 1;
s = v1 * v1 + v2 * v2;
} while ( 1 <= s || 0 == s);
x = v1 * sqrt(-2 * log(s) / s);
} else {
x = v2 * sqrt(-2 * log(s) / s);
}
phase = 1 - phase;
return x;
}
// 產生標準差爲S、均值爲E的正態分佈
double GaussRand(double S, double E) {
return GaussRand() * S + E;
}
// 把浮點數x按四捨五入,精確到小數點後兩位
float round2(float x) {
int ix = x * 100.0;
if (ix % 10 >= 5)
ix += 5;
return ix / 100.0;
}
private:
int m_countOfUsed, m_number;
float m_total;
bool goodSettings;
};
int main() {
srand(time(NULL));
float total;
int number;
cin >> total >> number;
RedPacket rp(total, number);
while (rp.nextRedPacket());
return 0;
}