项目里面有大量的榜单需求,很多场景下都是用zset来实现的。需求里面无一例外的都提到一个要求:分数相同的情况下,先到该分数的排前面。由于zset是分数优先,分数相同的时候用zset的member的字典序排列,并不满足先来后到这种需求。以前的做法基本都是分数拼凑一个时间量的做法:

  • 将zset的score值分成两部分:高位存分数,低位存时间差
  • 时间差一般是定一个截止时间x,x-now作为时间差
  • 用户a在x1时间达到了分数N,用户b在时间x2达到了分数N,x1<x2,那么x-x1>x-x2,所以拼凑后分数值,a的大于b的,所以a排b前面,实现了先来后到。

        这个方法存在这么些问题:

  • x不好确定。定的太远,那么x-now的值会比较大,时间差部分需要的位数更多,挤压分数的空间。x定的太小,项目延后下线或者复用后忘记修改也是坑。
  • 时间精度的问题。精度越高,需要的位数越多;精度低,那么出现相同时间的概率就越高,时间相同,那又变成字典序了。

        当前项目里面用了一个更通用的方法:用浮点数来表征score(zset的score本来就是用double),整数部分表示分数,小数部分表征先来后到:

  • 整数部分是分数,小数部分表示先来后到。先到达这个分数,小数值越大,这样排名就靠前。
  • 用户a到达分数n的时候,获取当前分数为n(整数部分是n),score值最小的member:
    • 如果当前没有member分数是n,说明用户是第一个到达n的(那些曾经是n,但是分数变化了的不用管),那么小数部分就赋一个大点的小数,比如0.9,那么用户的score就是n+0.9。
    • 如果查询到的member的score值是n+k1(k1是小数部分),那就构造一个比k1更小的小数k2,a的分数就是n+k2,因为k2<k1,所以a在zset里面会排在这个用户的后面,也就是后到排后面。构造的方法有很多,比如k2=1/(0.1+1/k1) 

实现这个功能的lua脚本如下:

local call=redis.call
-- return new score
local function add(key, field, score)
  -- get old score
  local o=call('zscore', key, field)
  local n
  if o then
    o=math.floor(o)
    n=o+score
  else
    n=score
  end

  local ss = call('zrangebyscore', key, '('..n, '('..(n+1), 'withscores', 'limit', 0, 1)
  local ns
  if #ss == 0 then
    ns = n+0.9
  else
    local dec=tonumber(ss[2])-n
    ns = n+1/(0.1+1/dec)
  end

  call('zadd', key, ns, field)

  return ns
end

return add(KEYS[1], ARGV[1], ARGV[2])

压测的结果,耗时比直接zadd多了54%,大概是3w/s

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐