Review代码思考:排行榜同积分按时间排序优化方案 | Lua开发实战

2,001 阅读6分钟

前言

端午节运营上了一个活动需求,其中需求有一个要求点,相同金币值要按照先手到达的顺序进行排序,而且是加了红色备注。同事在开发完毕之后,我review代码的时候发现为了实现这个更新操作,产生了大量的不必要代码,可读性太差,而且在每天凌晨对用户发放奖励,代码冗余太重,未考虑到性能要求。

所以趁着端午节自己来简单实现一个不一样的处理方式(不一定是最优),只是从不同的角度去看待解决方案。

## 最近大家更新太迟了,因为负责项目多了比较忙

## 下期下下期会有关于百度(高级t5/资深t6)的后端面试题

祝大家端午节快乐哦,别忘记学习啦 😁 😁 😁 !

有序集合同值如何排序?

在score相同的情况下,redis使用字典排序,所谓的字典排序就是按照”abc“这样首字母顺序去排序;我们来实践看下:

127.0.0.1:6379>
127.0.0.1:6379>
127.0.0.1:6379> zadd amu 10 xiaoming
(integer) 1
127.0.0.1:6379> zadd amu 10 amu
(integer) 1
127.0.0.1:6379> zadd amu 10 xiaohong
(integer) 1
127.0.0.1:6379> ZRANGE amu 0 -1 withscores
1) "amu"
2) "10"
3) "xiaohong"
4) "10"
5) "xiaoming"
6) "10"

从上面可以很清楚的看出来,相同值情况下,它会按照field的首字母进行比较返回排序名次。

Redis Zset实现排行榜按时间排序思考

  • Redis有序集合值是double类型8字节64位,完全可以分离开来,根据雪花算法逻辑存储
  • 简单化处理,一定不要复杂化(我同事使用 有序集合 + hash(存储最后更新时间戳),非常累赘繁重)
  • 同等分数情况下按照时间更新最早排序,所以高位必须是存储分数,低位存储时间戳
  • 注意分数溢出问题,提前预估用户大致到达的分数最高值
  • 每次更新用户积分都需要先 zscore 然后 zadd,就不能保证原子操作;一旦出现并发情况,需要注意这点解决原子操作

后端程序员必会:并发情况下redis-lua保证原子操作

三种实践方案

方案一:通过时间差计算出方案; 分数 + 结束时间戳(固定位数)

注意这里只是演示:并没有真正先拿到值再处理以后覆盖原值,详细请看到最后面

package.path = package.path..";~/redis-lua/src/?.lua"  --redis.lua所在目录

local json_encode = require "cjson" .encode
local redis = require("redis")

local reds, err = redis.connect('127.0.0.1',6379)

local key = string.format("dragon:boat:festival:date:%s", os.date('%Y%m%d')) -- 排行榜key
local endtime = 1623600000          -- 活动结束时间
local value   = 9999                -- 用户增加分数值
local sufix   = endtime - os.time() -- 用户更新分数时间距离结束时间的差值
local user_id = 1001                -- 用户id也就是有序集合的 field

local diff = 5 - string.len(sufix)  -- 计算时间戳差值长度是否小于5  因为是每天排行榜 所以最大的时间差为 86400
if diff < 5 then 
    for i=1,diff do
        sufix = 0 .. sufix
    end
end

local score   = value .. sufix      -- 组合成新的值
local res, err = reds:zadd(key, score, user_id)
print(sufix)
print(res)
print(key)

从上面大家可以看到整体的结构思路,主要是拿到分数更新是距离结束的差值,然后再和分数合并一起;分数在前,时间差在后;拿到集合里面的分数之后可以通过:截取固定位数拿到分数。上面结果集如下:

➜ ~ lua hello.lua

dragon:boat:festival:date:20210613

04403

可能大家会有疑问:获取score值是先取出来,处理成new_score后再存进去,还是会产生并发问题吧?这个问题非常的不错:以上操作并不是原子操作,并发情况下会导致我们socre不准确,文章结尾会对这个做处理。

方案二:通过进行位操作; 原理是 分数 + 结束时间戳(固定位数)

  • 首先:需要拿活动结束时间作为约束 endtime
  • 其次:对分数 score 先进行左移 27位再加上时间戳差值;同积分情况,越往后添加的,经过计算之后 new_score 越小;实现原理 new_score = score << 27 + (endtime - os.time())
  • 然后:我们可以通过 对从集合中拿到的 分数 进行 右移 27位;则会被去掉时间戳差值,显示真正的积分

代码实现如下

local redis = require("redis")

--- 获取集合中真实分数
--- @param score number 集合中用户分数
function get_score(score)
    return math.floor(score / (2 ^ 27)) 
end

--- 计算位运算以后的分数
--- @param point number 增加的分数
--- @param timestamp number 活动结束时间
function to_score(point, timestamp)
    point = tonumber(point) or 0
    timestamp = tonumber(timestamp) or 0
    local score = point * (2 ^ 27)+ (timestamp - os.time())
    return score
end

local endtime = 1623686400   -- 活动结束时间
local new_score = to_score(999, endtime)
local res, err = reds:zadd(key, new_score, user_id)
print(new_score)
print(get_score(new_score))

执行结果

134083555799
999

观察问题

虽然我们通过位左右移动操作在加上时间戳差值,尽管集合同分数相同下的概率变小了。但不知道聪明的你有没有发现,这里计算的是 秒时间戳,存在相同时间到达相同分数的情况,依然会出现集合按照元素的 字典排序 对于要求不是很高,可以完全忽略这种出现的概率。

如果是对结果集合超严谨:我们可以对时间戳精确到 毫秒 或者 微秒

## 要注意的是:

分数 = 等级 + 时间差 (当前系统时间戳)

分数是 64位的长整型 Long (有符号)

保证我们组合的位数在规定方位内即可

方案三:基于雪花算法思想实现

## snowflake的结构如下

0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000

① 第一位为未使用

② 后面41位为毫秒级时间(41位的长度可以使用69年)

③ 接着 5位datacenterId 和 5位workerId (10位的长度最多支持部署1024个节点)

④ 最后12位是毫秒内的计数(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号)

⑤ 一共加起来刚好64位,为一个Long型。(转换成字符串长度为18)

为什么说是用到了雪花算法的思想呢?这是因为项目里面使用lua实现学法算法并加以修改,所以再review代码时,我优先想到的就是利用雪花算法思想来实现排行榜。当然这个跟方案二还是有相同之处,只是为了提供多种方式来实现。

## 排行榜实现原理

1位高位不用 + 41位时间戳 + 22位表示积分

那是不是就可以表示成这样

0(最高位保留) | 000000000000 000000 000000(22位分数位) | 0 0000000000 0000000000 0000000000 0000000000(41位时间戳)

分数在高位,时间戳在低位,这样就可以保证不管时间戳是多少,分数越大,那么值就越大,也就符合我们需求

22位是符合我们业务需求的值:最大支持 (2^21-1)

41位时间戳最大支持毫秒级:2^40-1

优秀的你估计又想到了,22位不满足我们业务需求怎么办,值太小了?那么我们也是可以通过对41位时间戳进行 压缩 ,我们可以像方案一二方法一样对时间戳进行压缩:活动结束时间 - 当前时间戳,完全可以从64位压缩32位或者16位,这些都是可以的。下面我通过代码实现:

local time = os.time
local bits = {}
--- 对每一位进行位运算,然后将值返回
local function _bits_op(left, right, func)
    if left < right then
        left, right = right, left
    end
    local result = 0
    local shift = 1
    while left ~= 0 do
        local num_1   = left % 2  -- 取余 取得每一位(最右边)
        local num_2   = right % 2 -- 取余
        local ok, ret = pcall(func, num_1, num_2)
        ret = tonumber(ret) or 0
        result = shift * ret + result
        shift  = shift * 2
        left   = math.modf(left / 2)  -- 右移
        right  = math.modf(right / 2) -- 右移
    end
    return result
end

--- 或操作
function bits.bits_bor(left, right)
    return _bits_op(left, right, function(left1, right1)
        return (left1 == 1 or right1 == 1) and 1 or 0
    end)
end

--- 右移
function bits.bits_rshift(left, num)
    return math.floor(left / (2 ^ num))
end

--- 左移
function bits.bits_lshift(left, num)
    return left * (2 ^ num)
end

--- 获取分数
function bits.get_score(score, bitnum)
    score = tonumber(score) or 0
    local bit_num = tonumber(bitnum) or 41
    return bits.bits_rshift(score, bit_num)
end

--- 更新分数
function bits.to_score(point, curtime, bitnum)
    local points = tonumber(point) or 0
    local timestamp = tonumber(curtime) or 0
    local bit_num = tonumber(bitnum) or 41
    local score = 0
    score = bits.bits_bor(score, points)
    score = bits.bits_lshift(score, bit_num)
    score = bits.bits_bor(score, (timestamp - time()))
    return score
end

local score = 0    -- 当前分数
local cur_ponit = bits.get_score(score)
print(cur_ponit)
local new_score  = bits.to_score(cur_ponit + 99999, 1625068800)
local res, err = reds:zadd(key, new_score, user_id)
print(new_score)
print(bits.get_score(new_score))

生成结果

0                   -- 初始化真实分数

219900126533365579  -- 进行位运算操作

99999               -- 还原真实分数

左移和右移刚工作时,可能面试题大都会有这些。通俗的说:位移是将数据转为二进制后,进行左右移动;如果向左移动,则右边补零;如果是向右移动,则左边补零,溢出的则删掉。所以更简单的理解为:向左移动为整数的乘法;向右移动为整数的取整除法。这样大家就可以更好理解上面案例代码

举例子

左移(<<):将第一个数向左移动第二个数指定的位数,空出的位置  补零

## 注意
左移相当于乘法;左移一位相当于乘以 2

## 例如

a << 1 = a * 2  => a * 2^num


右移(>>):将第一个数向右移动第二个数指定的位数,空出的位置  补零

## 注意
右移相当于整除;右移一位相当于除以 2

## 例如
a >> 1 = a / 2  => a / 2^num

如何保证原子操作

并发情况下redis-lua保证原子操作这篇文章已经很清楚的写明并发情况下保证操作原子性,这点很考验一个 高级开发工程师的基础,要知道现在不管大的或小的公司,在用户量和数据量较大的业务场景下很容易出现并发场景,所以大家一定要了解且会运用。

package.path = package.path..";~/redis-lua/src/?.lua"  --redis.lua所在目录
local json_encode = require "cjson" .encode
local redis = require("redis")
local reds, err = redis.connect('127.0.0.1',6379)

-- 更新用户分数值
local _update_score = [[
    local key    = KEYS[1]
    local field  = ARGV[1]
    local score  = ARGV[2]
    local timestamp  = ARGV[3]

    score = tonumber(score) or 0                        -- 需要增加的用户分数值
    timestamp = tonumber(timestamp) or 0                -- 时间差值

    local cur_score = redis.call('zscore', key, field)  -- 获取用户当前分数值 
    cur_score       = tonumber(cur_score) or 0  

    local ponit = math.floor(cur_score / (2 ^ 27))      -- 右移获取用户真实分数值 去掉时间差值以后 
    ponit       = tonumber(ponit) or 0

    local ret = {"score", 0 ,"res", 0}                  -- 定义返回table

    local num = ponit + score                           -- 用户增加后的分数值
    local res = num * (2 ^ 27) + timestamp              -- 经过左移处理后的分数值

    redis.call('zadd', key, res, field)                 -- 塞进集合 

    ret[2] = num
    ret[4] = res

    return ret
]]

local key = string.format("dragon:boat:festival:date:%s", os.date('%Y%m%d')) -- 排行榜key
local endtime = 1623600000          -- 活动结束时间
local score   = 9999                -- 用户增加分数值
local sufix   = endtime - os.time() -- 用户更新分数时间距离结束时间的差值
local user_id = 1001                -- 用户id也就是有序集合的 field

local res , err = reds:eval(_update_score, 1, key, user_id,  score, sufix)
print(json_encode(res))

结果集

dragon:boat:festival:date:20210614

["score",19997,"res",2683951855961]

127.0.0.1:6379> ZSCORE dragon:boat:festival:date:20210614 1001
"2683951855961"

当然建议大家可以使用 evalsha 函数来实现,可以将脚本放进缓存,不需要每次都重新将脚本加载一次,减少网络开销和响应时长,保证我们业务能快速响应。

总结

上面三种方案是我根据同事提交的代码做了不同的方案处理,尽量保证业务简单化,不然本来十几行代码能解决的事情,却要弄出上百行代码实现。但是最终我们是要实现按照redis以外的规则对有序集合做排序处理。只是我们在实现功能时不要一味的有这种现象:”能实现就行,不管过程如何!“,这种想法是不对的,你只有不断的去优化你的代码,才能提高自己的编码水平,让人看着赏心悦目。这是对自己负责

重点一定要保证并发情况下的原子操作

或许大家有以下疑问

① 大家可能会问,lua5.3版本引入了bit类库,为什么不直接使用?

② 也有可能会问,跟你同事实现方式对比起来,性能如何,有何优点?

## 注意注意

下期lua教程会解答大家的疑问,这里留下疑问;大家也可以自行查阅资料,实际运行看下性能

如有不同的方案或者疑问,欢迎大家批评指正