从大表中随机取出指定数量的记录

323 阅读3分钟

背景

这样一个业务,有一个滚动抽奖的活动,抽奖池中的客户会有数十万,把所有客户都给前端滚动起来,前端的滚动动画会非常卡顿。

为了随机和安全,倒计时结束后抽到谁,要由后端指定。客户数量太大,前端没必要滚动所有数据,可以接受随机取出一部分客户送到前端去滚动。等到抽奖结束时,将服务器抽到的中奖者插入到滚动列表里,并且让前端滚动停留在这个中奖者上。

接下来,就是解决如何在大数据量中随机抽取指定数量的用户。

方案一:后端随机抽取

后端取出所有数据,从中随机取 1000 个

$users = $db->fetchAll('select id, name from users');

// 随机取出1000个,这里我偷懒奖数组乱序后取前1000个
shuffle($users);
return array_slice($users, 0, 1000);

但这样取出所有数据,可能会导致内存占用大,且其实只需要很少的数据,取出所有数据也是浪费

后端随机计算 1000 个 id,直接取指定数据

$minId = $db->fetchColumn("select min(id) from users"); // 伪 sql
$maxId = $db->fetchColumn("select max(id) from users");
$ids = [];
for ($i = 0; count($ids) < 1000; $i++) {
    $rand = rand($minId, $maxId);
    $ids[$rand] = $rand; // 防止取到重复
}
$users = $db->fetchAll("select name from users where id in (" . implode(', ', $ids) . ")";
return $users;

此时如果 id 不连续(比如有删除数据、因为事务回滚导致有些自增 id 被消耗),或者还要追加其他 where 条件时,事先计算的 id 可能根本不符合要求,导致最终取出的数量不足。

对取出数量不足的处理

按照我的业务,只需要随机取出一些用户在屏幕上滚起来,是否真的恰好 1000 其实不重要,可以接受数量略微不足。

如果需要恰巧取出 1000 个,可以用循环处理,取多次,直到满足数量。这样处理会多一次,但也能接受。

$size = 1000;
$users = [];
while (true) {
    $tmpUsers = "直接抄之前方案一的实现";
    $users = array_uniq(array_merge($users, $tmpUsers));
    if (count($users) >= 1000) {
        $users = array_slice($users, 0, 1000);
        break;
    }
}
return $users;

方案二:从数据库直接抽取

上述讨论,若能接受数量略微不准,也可以不用后端语言处理,直接交给数据库。

分成多个 batch,每个 batch 取固定位置的数据

# 从1000个batch,每个batch里,取固定位置的一个

select @batch := floor(count(*) / 1000) from users; 
select @mod := FLOOR(1 + RAND() * (@batch - 1));
select id, name from users where id % @batch = @mod;

最终取出的数量可能会比 1000 多一个,或少一个。每个元素的间隔相同,数量可空,相对随机。

分成多个 batch,每个 batch 里随机取一个

select @batch := floor(count(*) / 1000) from users;
select * from users where id % @batch = FLOOR(1 + RAND() * (@batch - 1))
limit 1050;

每个元素都有几率出现,更随机,总数会维持在1000上下。但几率的事情说不准,可能数量会非常大,也可能非常小,所以加一个limit 来限制非常大的情况。

数据量极小时的不足

当数据量极小时(比如开发、测试时,或是功能刚上还没有累积到足够的数据时),上述各方案都会有瑕疵,可能需要取很多次才能取到指定数量,甚至永远取不到。

根据业务,活动上线后会先收集数据,一周后才会抽奖,预测到时数据会较大。测试环节直接按照大数据量来测试。事实当抽奖时,确实已有数万数据,可以接受这个代码上的不足。