谈Redis的SRANDMEMBER/SPOP公平性

3,125 阅读8分钟

背景


我是一名前端,但由于公司后端采用 Egg.js ,所以偶尔还会客串下后端开发[捂脸]。

由于公司项目出现了一些状况,导致消费者对品牌意见很大,经品牌统一,在活动后,把没有成功抢兑换成功礼品、而又满足兑换资格的用户,集中起来,做一个抽奖。

比如。最终满足兑换资格报名抽奖的用户有 10000 名用户,一等奖 200 名,二等奖 500 名,剩余全是三等奖。

那么,方案是可以先把这些用户存放到 redisset 当中,然后先 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_MAXstdlib.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++ 写的,会不会这里的随机数也是有上述的问题呢?他是怎么解决这个问题的呢?稍后也会写一下这相关的问题。

验证猜想

到谷歌找


先到谷歌找一下,发现有个提问redisspop 非乱序。后面发现是 2.2 版本(2011)以下的问题。

到源码找


接着到 redisgithub 源码仓库看下。

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;
}

上面用到了两个关键的随机函数:dictGetRandomKeyintsetRandom

/* 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随机数之坑


在验证猜想的过程当中,发现 redislua 封装的 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(响萤)