浮点数字节序比较

在开发LedisDB的时候,我曾考虑将zset的score使用跟redis一样的double类型,但是却没想好如何将double在底层LevelDB或者RocksDB下存储,使其能够支持zset中zrangebyscore等命令,所以只能考虑使用int64类型来代替。但在开发qdb的时候,最开始我们仍然只是支持int64,但最终通过努力,支持了double,使其能跟redis的zset api完全兼容。其实后来发现,实现很简单。

LevelDB和ZSet

这里需要简单说明一下LevelDB(RocksDB只是它的一个衍生优化版本,但最核心基本的原理还是一样的)。

LevelDB是一个高性能的KV存储库,数据在LevelDB里面是根据key来进行排序存储的,而key和value是任意的字节数组。在外部看来,LevelDB里面的数据就是一个有序map,map里面的key是顺序存储的,如下:

{
    "key1" : "value1",
    "key2" : "value2",
    ...
}

在redis里面,zset是一个有序集合,每一个member都对应一个score,member是唯一的,score则可能重复。我们可以通过score进行很多处理,譬如获取[score1, score2]这个区间的所有元素,或者获取某个member对应的score再整个zset里面的rank。为了实现上面的需求,我们需要在内部用一个有序的数据结构来存储score以及其对应的member,redis使用skip list来进行处理,那如何将这样的映射关系在LevelDB里面很好的处理呢?

因为LevelDB将数据是按照key顺序存储的,所以我们只需要将score的信息绑定到key上面,就能很容易的实现一个简易的skip list。对于一个zset里面的member,可能在LevelDB里面实际的key结构如下:

key:score:member

对于一个zset来说,key和member都是arbitrary bytes array,可以很方便的绑定到LevelDB的key上面,但是score可是double的,如何绑定上去参与排序呢?

LedisDB ZSet int64 score

再说double之前,先来聊聊LedisDB的做法,LedisDB使用的score是int64,对于一个int64来说,计算机内部是使用8 bytes进行存储的,下面是例子,会打印不同int64数据在小端序和大端序下8字节array十六进制表现:

import "fmt"
import "math"
import "encoding/binary"

func p(a int64) {
    b1 := make([]byte, 8)
    b2 := make([]byte, 8)

    binary.LittleEndian.PutUint64(b1, uint64(a))
    binary.BigEndian.PutUint64(b2, uint64(a))

    fmt.Printf("%0x\t%0x\n", b1, b2)
}

func main() {
    p(0)
    p(1)
    p(0xFFFF0000)
    p(math.MaxInt64)
    
    p(-1)
    p(-200)
    p(-300)
    p(math.MinInt64)
}

输出结果如下:

0000000000000000    0000000000000000

0100000000000000    0000000000000001
0000ffff00000000    00000000ffff0000
ffffffffffffff7f    7fffffffffffffff

ffffffffffffffff    ffffffffffffffff
38ffffffffffffff    ffffffffffffff38
d4feffffffffffff    fffffffffffffed4
0000000000000080    8000000000000000

第一列是小端序打印的结果,第二列是大端序打印的结果。首先我们可以看到,对于0来说,无论什么端序,都是全0,对于正整数来说,我们可以看到,0xFFFF0000是铁定大于1的,但是在对应的小端序存储的bytes array里面,会发现如果按照字节序进行排序0100000000000000是大于0000ffff00000000的,而大端序的结果0000000000000001是小于00000000ffff0000的。对于负整数也是一样,-200是大于-300的,但是小端序的结果是小于,而大端序的结果是大于。

所以我们可以知道,对于一个整数,我们需要使用大端序的方式将其绑定到LevelDB的key上面,这样才能参与正常的排序。但是我们又发现,如果直接这样处理,负数的大端序结果是铁定大于正数的,譬如-1的ffffffffffffffff就大于1的0000000000000001,所以为了正常的处理整数的排序,我们只需要在key上面加一个前缀标志就可以了。LedisDB里面做了如下处理:

key<38ffffffffffffff:member
key<ffffffffffffffff:member
key=0000000000000000:member
key=0000000000000001:member

因为=的ascii值大于<,我们将正整数和0前面加上=,将负数前面加上<,这样在进行字节序排序的时候,正数和0一定能排在负数的后面,也就是完全能满足从小到大排序的需求了。

QDB ZSet double score

上面可以知道,我们使用大端序加上一个前缀标志,就能很好的处理int64类型的score的排序,但double可没有这么简单,不然LedisDB早就支持了。

首先我们来看看double类型IEEE754规范,对于一个double来说,使用8 bytes存储,格式如下:

  • 符号: 1 bit (0为正,1为负)
  • 指数: 11 bits
  • 分数: 52 bits
double format
double format

对于一个64 bits的double,如果指数为e,那么它对应的值计算方式为:

如果指数越大,那么double的值越大,如果分数越大,在相同指数的情况下面double值也越大。所以我们如果将double使用大端序存放到8字节array里面,我们就能直接进行字节序比较,但这个仅限于正的double情况下。因为对于负的double,它除了最高位bit为1这一点不一样之外,其余完全跟相反的正数底层存储方式一模一样,所以如果我们直接将其转为大端序比较,会发现结果是相反的。一个简单地例子:

func d(a float64) {
    b := make([]byte, 8)

    binary.BigEndian.PutUint64(b, math.Float64bits(a))

    fmt.Printf("%064b\n", binary.BigEndian.Uint64(b))
}

func main() {
    d(0)
    
    d(1)
    d(2)
    
    d(-1)
    d(-2)
}

结果如下:

0000000000000000000000000000000000000000000000000000000000000000
0011111111110000000000000000000000000000000000000000000000000000
0100000000000000000000000000000000000000000000000000000000000000
1011111111110000000000000000000000000000000000000000000000000000
1100000000000000000000000000000000000000000000000000000000000000

这里在插入一片广告文章,推荐阅读,这里

当初LedisDB就是因为没有办法很好的处理负double的排序比较问题,才采用了int64,但是真的没有办法吗?为啥int64类型的负数能通过大端序进行字节序比较呢?我们知道,在计算机内部,负数是使用补码存储的,也就是反码加1,负数的反码,就是在源码的基础上,符号位不变,其余各个位取反。对于-1来说,它的源码为[10000001],反码为[11111110],补码为[11111111]。(为了简单,使用int8表示的)。就因为它有了取反的这一步,我们才能直接用大端序字节序比较。

所以对于double的负数,我们也仅仅需要干一件事情,就是取反,那么它的大端序字节序比较就是正常的了。在QDB里面,对于一个double,我们做了如下处理:

// We can not use lexicographically bytes comparison for negative and positive float directly.
// so here we will do a trick below.
func float64ToUint64(f float64) uint64 {
    u := math.Float64bits(f)
    if f >= 0 {
        u |= 0x8000000000000000
    } else {
        u = ^u
    }
    return u
}

func uint64ToFloat64(u uint64) float64 {
    if u&0x8000000000000000 > 0 {
        u &= ^uint64(0x8000000000000000)
    } else {
        u = ^u
    }
    return math.Float64frombits(u)
}

如果一个double数据值大于等于0,那么我们将最高位bit设置为1,而对于负数,我们直接取反,就这一步简单的操作,就能让我们完美的支持double的score了。

总结

让QDB的zset支持double score,其实很简单的一件事情,但我在开发LedisDB的时候竟然没有想到如何去解决。我们很多时候,往往追求高大上的东西,譬如架构,设计模式等,但忽略了计算机最基本的底层知识(上面这些其实也就是大小端序,补码,反码等),所以有时候还真的重新好好把最基本的东西给回顾一下。

最后,在推荐一下QDB,QDB是一个类似Redis的KV数据库,底层使用LevelDB/RocksDB作为存储引擎,解决了Redis内存限制问题,同时能支持string,hash,list,set和zset这几种数据结构,性能卓越,你也可以认为它是LedisDB的升级版本。

网址 https://github.com/reborndb/qdb

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容