Redis 里 String 的数据结构都不陌生了, 这里介绍一点使用 StringBitMap 特性时, 遇到的反直觉的特性.

前情回顾

学C语言讲ASCII编码的时候, 我们就知道了字符的编码:

'h'.ord
# 104
'h'.ord.to_s(2)
# "1101000"
'i'.ord
# 105
'i'.ord.to_s(2)
# "1101001"

# (<=Byte High)    0110 1000    0110 1001    (Byte Low=>)
# => Memory growth direction =>  

关于大端模式和小端模式.

上面例子, h 对应的二进制的存储为 0110 1000, 正想我们看到的这样, 右边为字节的低位, 左边为高位, index 从左向右数 依次增大.

也是为了顾及人类的可读性, 网络传输中使用了 大端模式 .

但是如此一来, 就不方便机器处理了, 因为 大端模式 跟 内存的增长顺序相反.

小端模式 就是字节的高位对应内存地址的高位, 机器处理的时候就省去了翻转的操作, 提升效率.

Redis 的 String 使用小端模式

UTF8下, 每个英文字符占一个byte, 一个byte八个bit. unshift 方法把每个取出的值从左边插入, 类似 lpush .

require 'redis'

conn = Redis.new

conn.set('key', 'hi')
p value = conn.get('key')
# "hi"

bits = []
(value.bytesize * 8).times do |index|
  bits.unshift conn.getbit('key', index)
end
p bits.join
# "1001011000010110"

Redis中, 以 BitMap 存贮的数据跟普通的字符串并没有什么不同, 只是我们赋予了这些二进制不同的含义.

我们可以按照 01101000 01101001 的顺序依次设置bit位, 然后用字符串的形式来读取 (只是试验, 没人会这样干):

conn.del('key')
bits = '0110100001101001'.split('').map(&:to_i)
p bits # [0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1]
bits.each_with_index do |value, index|
  conn.setbit('key', index, value)
end
p conn.get('key')
# "hi"

Redis 的 bitcount 按字节计算

startend 是闭区间, 单位是 byte .

BITCOUNT key [start end]

官方文档虽然有写是根据bytes, 但是直觉反应是按照bit计算啊, 如果没注意就很惨 ~

conn.del('key')

conn.setbit('key', 6, 1)
conn.setbit('key', 7, 1)
conn.setbit('key', 8, 1)
conn.setbit('key', 16, 1)

p conn.bitcount('key')
# 4
p conn.bitcount('key', 0, 1)
# 3

第一个byte是 0~7, 第一个byte是 8~15, 第一个byte是 16~23.

假设 BitMap 中保存的是用户按天打卡的数据, 如果想要某个月份的打卡总数, 只能自己算了.

计算某月的打卡总数

Redis 在 3.2 之后提供了 bitfield 命令, 用来操作连续的bit位.

该命令限制每次最多操作64个位 (有符号数模式64个, 无符号模式63个).

估计这是一个很小众而且参数复杂的命令, Ruby 的 Redis Client 都没有支持它.

首先, 我们利用 Ruby 的打开类特性, 把自定义的 bitfield 的 get 方法塞入 Redis 类.

(bitfield 还有很多其他用法, 这个方法都舍去没用)

class Redis
  def bitfield_get(key, offset = 0, len = 1, signed: false)
    type = "#{(signed ? 'i' : 'u')}#{len}"
    synchronize do |client|
      client.call([:bitfield, key, 'get', type, offset]).first
    end
  end
end

我们在 key 为 record 的 String 上存储用户的打卡记录, 假设index从1开始, 跳过0.

conn.del 'record'
# 假设 record index 从 1 开始
conn.setbit 'record', 1, 1 # 1月1号
conn.setbit 'record', 32, 1 # 2月1号
conn.setbit 'record', 33, 1 # 2月2号
conn.setbit 'record', 40, 1 # 2月9号
conn.setbit 'record', 59, 1 # 2月28号
conn.setbit 'record', 60, 1 # 3月1号

读取二月份打卡天数:

result = conn.bitfield_get('record', 32, 28)
# 201850881
result.to_s(2)
# "1100000010000000000000000001"
# 由于Redis是小端模式, 也就是index从左边往右数, 跟内存增长方向一致
result.to_s(2).count('1')
# 4

Ruby Redis Client 中包含了一个 method_missing:

  def method_missing(command, *args)
    synchronize do |client|
      client.call([command] + args)
    end
  end

所以我们也可以不使用自定义的 bitfield_get 方法, 直接利用 method_missing 传参:

conn.bitfield(record_key, 'get', "u#{len}", offset)[0]

这样的缺点也很明显, bitfield 本身的参数已经很复杂了, 如此一来参数的含义就更难以理解.

小结

require 'redis'
require 'date'

conn = Redis.new

conn.del 'record'
# 假设 record index 从 1 开始
conn.setbit 'record', 1, 1 # 1月1号
conn.setbit 'record', 32, 1 # 2月1号
conn.setbit 'record', 33, 1 # 2月2号
conn.setbit 'record', 40, 1 # 2月9号
conn.setbit 'record', 59, 1 # 2月28号
conn.setbit 'record', 60, 1 # 3月1号


class Redis
  # https://redis.io/commands/bitfield
  def bitfield_get(key, offset = 0, len = 1, signed: false)
    type = "#{(signed ? 'i' : 'u')}#{len}"
    synchronize do |client|
      client.call([:bitfield, key, 'get', type, offset]).first
    end
  end
end

def record_count(conn, record_key, year_month)
  # 当月的一号
  target = Date.strptime(year_month, '%Y-%m')

  # 从元旦当月一号的偏移量
  start_of_year = Date.new(target.year)
  offset = (target - start_of_year).to_i + 1

  # 当月有多少天
  end_of_month = Date.new(target.year, target.month, -1)
  len = (end_of_month - target).to_i + 1

  p "offset:#{offset}, len:#{len}"
  result = conn.bitfield_get(record_key, offset, len)
  # result = conn.bitfield(record_key, 'get', "u#{len}", offset)[0]
  result.to_s(2).count('1')
end

p record_count(conn, 'record', '2019-02')
# "offset:32, len:28"
# 4

Reference

https://redis.io/commands/bitcount

https://redis.io/commands/bitfield

http://doc.redisfans.com/