背景
我是一名前端,但由于公司后端采用 Egg.js
,所以偶尔还会客串下后端开发[捂脸]。
由于公司项目出现了一些状况,导致消费者对品牌意见很大,经品牌统一,在活动后,把没有成功抢兑换成功礼品、而又满足兑换资格的用户,集中起来,做一个抽奖。
比如。最终满足兑换资格报名抽奖的用户有 10000 名用户,一等奖 200 名,二等奖 500 名,剩余全是三等奖。
那么,方案是可以先把这些用户存放到 redis
的 set
当中,然后先 SPOP
出一等奖,然后再 SPOP
出二等奖,再把剩下的名单全拿出来发放三等奖。
那么问题来了,SPOP/SRANDMEMBER
它内部的随机是否公平?
redis
内部是直接通过取一随机数然后返回来保证效率?还是说采用了洗牌算法来做 SPOP/SRANDMEMBER
?虽说洗牌算法复杂度可以到 O(n)
,但一旦 N
达到一个比较大的数值的时候,m
的取值对它的性能影响是否还是线性的而上限是 O(n/2)
?还是说它有更好的算法?想一探究竟。
关于随机数
在上学阶段已经知道,计算机里的随机数,如果不是通过物理现象产生的随机数,都是伪随机数。
只要这个随机数是由确定算法生成的,那就是伪随机。只能通过不断算法优化,使你的随机数更接近随机。 通过真实随机事件取得的随机数才是真随机数。[1]
故我们先不考虑因计算机伪随机数导致的不公平性,当然可以通过类似 random.org/ 提供的随机数api来做,但也受限于第三方服务,所以比较好的是把用 api 获取到的随机数作为随机数种子而非使用类似于 srand((unsigned)time(NULL));
这样的随机种子方法。
我的猜测
一般我比较喜欢通过自己的猜测,先把思路列出来,再一步步进行验证,即使验证出自己的想法全错也无所谓,哈哈。
猜测:redis直接用范围随机,不公平
猜测 redis
随机一个元素的伪代码,js
版本:
// js
let list = [1, 2, 3];
let [ result ] = list.splice(Math.floor(Math.random() * list.length), 1);
console.log(result);
也许这个版本看不出什么问题。
但 redis
是有 C
语言写的,我们来写个 C
的代码看看:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(void){
srand((unsigned)time(NULL));
int nums[10] = {4, 5, 2, 10, 7, 1, 8, 3, 6, 9};
printf("%d ",nums[rand() % 10]); // [0,9]
return 0;
}
一眼看过去好像没什么,但其实暗藏杀机。
但其实 rand()
函数只能够生成 [0,RAND_MAX]
范围内的整数。而 RAND_MAX
是 stdlib.h
头文件中的一个常数。
ISO IEC 9899 2011 (C11)标准中未规定 RAND_MAX 的具体数值。但该标准规定了RAND_MAX 的值应至少为32767。[2]
:::warning
这样直接导致了 nums[rand() % 10]
里面不同的元素的随机概率不一致。
:::
以目前大部分的解决方案而言,怎么扩大随机范围?
我看网上有不少的做法是:
先用rand()生成一个[0,RAND_MAX]范围内的随机数,然后使用这个随机数除以RAND_MAX这样就得到了一个[0,1]范围内的浮点数,我们只需要将这个浮点数乘以(b - a)再加上a即可,相当于浮点数就是[a,b]范围内的比例位。[3]
简单地说,就是将原来的随便的随机数改变成了随机比例,然后再用范围数乘以比例,就可以得出范围内的随机数。
这么想看上去也没错,但实际上可以说是完全错误的做法,因为这样做会有一些数永远取不到。
道理很简单,因为本身 RAND_MAX
本身数量就有限,导致算出来的随机的比例数量也跟 RAND_MAX
一致。那么也就导致比例乘以范围后,数量也就跟 RAND_MAX
一致。
对 JS 的猜测
上面的情况就让我延伸出另外一个问题:
JS 里的随机数 Math.random
函数不也就是一个随机比例么?V8
也是 C++
写的,会不会这里的随机数也是有上述的问题呢?他是怎么解决这个问题的呢?稍后也会写一下这相关的问题。
验证猜想
到谷歌找
先到谷歌找一下,发现有个提问说 redis
的 spop
非乱序。后面发现是 2.2 版本(2011)以下的问题。
到源码找
接着到 redis
的 github
源码仓库看下。
先 clone
下代码,切换到我使用的版本 - 5.0,然后开始搜索。
搜索下关键词 SRANDMEMBER
或者 SPOP
。
然后发现在 redis/utils/srandmember/
下有相关的说明:
The utilities in this directory plot the distribution of SRANDMEMBER to evaluate how fair it is. See theshfl.com/redis_sets for more information on the topic that lead to such investigation fix. showdist.rb -- shows the distribution of the frequency elements are returned. The x axis is the number of times elements were returned, and the y axis is how many elements were returned with such frequency. showfreq.rb -- shows the frequency each element was returned. The x axis is the element number. The y axis is the times it was returned.
这也只是用概率分布来证明公平,没从算法层面证明不算。
源码线索:
redis/src/t_set.c
文件中
// Line 553
void spopCommand(client *c) {
robj *set, *ele, *aux;
sds sdsele;
int64_t llele;
int encoding;
if (c->argc == 3) {
spopWithCountCommand(c);
return;
} else if (c->argc > 3) {
addReply(c,shared.syntaxerr);
return;
}
/* Make sure a key with the name inputted exists, and that it's type is
* indeed a set */
if ((set = lookupKeyWriteOrReply(c,c->argv[1],shared.nullbulk)) == NULL ||
checkType(c,set,OBJ_SET)) return;
/* Get a random element from the set */
encoding = setTypeRandomElement(set,&sdsele,&llele);
/* Remove the element from the set */
if (encoding == OBJ_ENCODING_INTSET) {
ele = createStringObjectFromLongLong(llele);
set->ptr = intsetRemove(set->ptr,llele,NULL);
} else {
ele = createStringObject(sdsele,sdslen(sdsele));
setTypeRemove(set,ele->ptr);
}
notifyKeyspaceEvent(NOTIFY_SET,"spop",c->argv[1],c->db->id);
/* Replicate/AOF this command as an SREM operation */
aux = createStringObject("SREM",4);
rewriteClientCommandVector(c,3,aux,c->argv[1],ele);
decrRefCount(aux);
/* Add the element to the reply */
addReplyBulk(c,ele);
decrRefCount(ele);
/* Delete the set if it's empty */
if (setTypeSize(set) == 0) {
dbDelete(c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_GENERIC,"del",c->argv[1],c->db->id);
}
/* Set has been modified */
signalModifiedKey(c->db,c->argv[1]);
server.dirty++;
}
其中使用到了取随机元素的函数 setTypeRandomElement
,关键应该就在这里了。
// Line 208
int setTypeRandomElement(robj *setobj, sds *sdsele, int64_t *llele) {
if (setobj->encoding == OBJ_ENCODING_HT) {
dictEntry *de = dictGetRandomKey(setobj->ptr);
*sdsele = dictGetKey(de);
*llele = -123456789; /* Not needed. Defensive. */
} else if (setobj->encoding == OBJ_ENCODING_INTSET) {
*llele = intsetRandom(setobj->ptr);
*sdsele = NULL; /* Not needed. Defensive. */
} else {
serverPanic("Unknown set encoding");
}
return setobj->encoding;
}
上面用到了两个关键的随机函数:dictGetRandomKey
和 intsetRandom
。
/* Return a random entry from the hash table. Useful to
* implement randomized algorithms */
dictEntry *dictGetRandomKey(dict *d)
{
dictEntry *he, *orighe;
unsigned long h;
int listlen, listele;
if (dictSize(d) == 0) return NULL;
if (dictIsRehashing(d)) _dictRehashStep(d);
if (dictIsRehashing(d)) {
do {
/* We are sure there are no elements in indexes from 0
* to rehashidx-1 */
h = d->rehashidx + (random() % (d->ht[0].size +
d->ht[1].size -
d->rehashidx));
he = (h >= d->ht[0].size) ? d->ht[1].table[h - d->ht[0].size] :
d->ht[0].table[h];
} while(he == NULL);
} else {
do {
h = random() & d->ht[0].sizemask;
he = d->ht[0].table[h];
} while(he == NULL);
}
/* Now we found a non empty bucket, but it is a linked
* list and we need to get a random element from the list.
* The only sane way to do so is counting the elements and
* select a random index. */
listlen = 0;
orighe = he;
while(he) {
he = he->next;
listlen++;
}
listele = random() % listlen;
he = orighe;
while(listele--) he = he->next;
return he;
}
/* Return random member */
int64_t intsetRandom(intset *is) {
return _intsetGet(is,rand()%intrev32ifbe(is->length));
}
很明显,intsetRandom
中使用了 rand()
对元素数量取模命中了概率不均匀。
而 dictGetRandomKey
当中的 random
函数,调用来自 glibc
里的 __random_r
函数,这里提供 glibc
仓库中 random
函数源码。
// 核心代码
if (buf->rand_type == TYPE_0)
{
int32_t val = ((state[0] * 1103515245U) + 12345U) & 0x7fffffff;
state[0] = val;
*result = val;
}
else
{
...
}
这里就可以看出,random
函数是能生成 [0 - (2^31 - 1)]
之间的随机数的。
但由于 dictGetRandomKey
中也是利用 random
对元素长度直接取模,故也是不公平的。
结论
综合我从源码上面的情况来看,得出结论:
【猜想:redis直接用范围随机,不公平】,猜想正确。
redis的lua随机数之坑
在验证猜想的过程当中,发现 redis
对 lua
封装的 random
函数存在重大缺陷。
// deps/lua/src/lmathlib.c - line 181
static int math_random (lua_State *L) {
/* the `%' avoids the (rare) case of r==1, and is needed also because on
some systems (SunOS!) `rand()' may return a value larger than RAND_MAX */
lua_Number r = (lua_Number)(rand()%RAND_MAX) / (lua_Number)RAND_MAX;
switch (lua_gettop(L)) { /* check number of arguments */
case 0: { /* no arguments */
lua_pushnumber(L, r); /* Number between 0 and 1 */
break;
}
case 1: { /* only upper limit */
int u = luaL_checkint(L, 1);
luaL_argcheck(L, 1<=u, 1, "interval is empty");
lua_pushnumber(L, floor(r*u)+1); /* int between 1 and `u' */
break;
}
case 2: { /* lower and upper limits */
int l = luaL_checkint(L, 1);
int u = luaL_checkint(L, 2);
luaL_argcheck(L, l<=u, 2, "interval is empty");
lua_pushnumber(L, floor(r*(u-l+1))+l); /* int between `l' and `u' */
break;
}
default: return luaL_error(L, "wrong number of arguments");
}
return 1;
}
发现了没有,上面代码直接命中了猜想里的数量坑。
在使用 redis
中此函数时务必要注意了。
V8的随机函数
上文提到 v8
的随机函数,查阅到文章:Chrome V8引擎系列随笔 (1):Math.Random()函数概览
文中提及到到 v8
两个版本乱度的不同,但范围却是足够大了。并没有猜想中出现的问题,v8
真棒!
参考文献
[1] 真/伪随机、以及随机算法
[2] 百度百科 - RAND_MAX
[3] C语言生成指定范围内的随机数
[4] Chrome V8引擎系列随笔 (1):Math.Random()函数概览
欢迎转载,转载时请标注来源出处。
Scott Leung(响萤)