Aug 7, 2018 - CockroachDB-数据KV化的方法

简介

CockroachDB是一款开源的newSql数据库,其底层存储使用的是RocksDB,本文翻译了官方博客上,介绍如何将类mysql的行数据转化为KV的处理方法。博客原文

Content

一个SQL表有多行,其中每一行又有多个列,每一列都有一个类型(布尔,整型,浮点,字符串,字节等)。每个表也有会关联的索引来支持高校的行数据检索。那么如何将这样的数据最终映射到键值对(string:string)这样的模型呢?

首先,CockroachDB的内部KV API支持很多操作,在此只介绍两个:

  • ConditionalPut(key, value, expected-value) - 有条件地写入值,当该key的值与期望的值相同。
  • Scan(start-key, end-key) - 左闭右开地检索在这个范围内的key

Key的编码

首先要有一种编码方式把SQL表的行信息编码进key当中。比如给一组数据<1,2.3,"four">,我们可以把这个数据编码成/1/2.3/"four"

为了可读,我们用斜杠来作为一个虚拟的分隔符。数据的编码实现方法在这里不讨论。(然后介绍一个奇怪的不可用的编码方法,可能是想举个反例吧,就无视好了)。

在讨论编码方案之前,我们先看一下要编码的SQL表数据。在CockroachDB中,每个表在创建的时候会被分配一个唯一的64位整型ID。这个表ID用来将这个表的所有KV数据联系在一起。现在来考虑下列的表和数据:

CREATE TABLE test (
	key 		INT PRIMARY KEY,
	floatVal  FLOAT,
	stringVal STRING
)

INSERT INTO test VALUES(10, 4.5, "hello")

每个CockroachDB都必须有一个主键,这个主键可能有一到多列组成;在上述的test表中,主键只有单列。CockroachDB将每个非主键列的数据存储在不同的key中,以主键key值为前缀,以列的名字为后缀。比如行<10,4.5,"hello">test表中的存储会是:

key val
/test/10/floatVal 4.5
/test/10/stringVal “hello”

在这个标书中,我们使用/test/来代替标识表ID,用/floatVal/stringVal代替标识列ID(每个行都有一个表内唯一的ID)。需要注意的是,主键在我们的编码中紧跟表ID。这在CockroachDB的SQL实现中,是索引扫描的基础。

假设表的元数据长这样:

key val
test Table ID 1000
key Column ID 1
floatVal Column ID 2
stringVal Column ID 3

那么上述行数据,就是这样的:

Key Value
/1000/10/2 4.5
/1000/10/3 "hello"

文章剩下的部分,都会使用这样的表述方式。

[你也许认为这些key的公共前缀设计/1000/10在key中重复出现是浪费空间(注:比如用一个更短的key的字典表来替代),但底层存储RockdsDB,使用前缀压缩来消除了这样的开销。(注:所以在应用层不需要在这个上面重复工作)]

机智的读者会发现,在这样的kv对中,主键列的值已经包含在key中了,所以对主键列,CockroachDB就省略了。

具体到某一行数据,他的每列数据总是存储在相邻的位置,因为他们的key前缀都是主键列,是一样的(在CockroachDB中,kv数据是单调序列,这样的特性是天然的。)这样,取某一行的某几列数据的时候,需要扫描那一列的所有key,CockroachDB内部就是这样做的。

假设查询:

SELECT * FROM test WHERE key = 10

会被翻译成:

Scan(/test/10/, /test/10/Ω)

Ω符号标识最后一个可能key的后缀。查询执行引擎之后会通过这些key,构造出被查询的列数据。

空值列(介绍Sentinel Key)

一个小插曲:没有被标记为NOT NULL的列值可以为NULL。CockroachDB不存储NULL值,使用key的缺失来标识这一列是空值。这样又有一个奇怪的小事情,如果一行数据,除了主键列,全部为空,那么这一行数据在CockroachDB中就不存在了(注:因为主键列的数据用key来标识了)。为了搞定这样的情况,CockroachDB会给每一行写一个哨兵key(Sentinel Key),这个哨兵key只包含表ID,主键列的值,没有任何其他非主键列的列ID。比如对于行数据<10,4.5,"hello">,哨兵key就是/test/10

Secondary Index

那么Secondary Index又咋么搞咧? 比如: CREATE INDEX foo ON test (stringVal) 这个语句会在stringVal列上创建一个非唯一的secondary index。同样的,索引kv化时,key以表ID作为前缀。但我们希望把索引数据跟行数据分开。所以每个索引都有一个表内唯一的ID,包括主键列的索引(注:所以上面举的例子需要重新补充一下,但是无伤大雅): /tableId/indexId/indexColumns[/columnId]

那么例子里的行数据就会变成

Key Value
/test/primary/10
/test/primary/10/floatVal 4.5
/test/primary/10/stringVal "hello"

现在我们增加了一个索引数据: Key | Value —| — /test/foo/"hello"/10 |

key中不仅有该索引的ID和值,在最后还附加上了主键列的值,原文描述是在结尾附加{ {主键列的值} - {索引列的值} }。这里原文罗里吧嗦了一堆为什么要加上主键列。我个人理解,跟mysql的二级索引原理一样,是通过二级索引查找到主索引,然后查找行数据。 (注:这里我有个疑问就是,不加上主键列,把主键列作为value,照样可以起到搜索的作用。莫非是RocksDB层,只扫描KEY和查询值是两步操作?这样可以更高效) 假设现在加一行<4,NULL,"hello">进表,那么这个表的所有数据就会变成:

Key Value
/test/primary/4 Ø
/test/primary/4/stringVal “hello”
/test/primary/10 Ø
/test/primary/10/floatVal 4.5
/test/primary/10/stringVal “hello”
/test/foo/”hello”/4 Ø
/test/foo/”hello”/10 Ø

假设执行: SELECT key FROM test WHERE stringVal = "hello"

执行计划器会发现在stringVal这一列上有索引,然后就会把查询翻译成: Scan(/test/foo/"hello"/, /test/foo/"hello"/Ω) 然后就会找到下列的key:

Key | Value — | — /test/foo/”hello”/4 | Ø /test/foo/”hello”/10 | Ø 然后通过附在最后的主键(前缀),去搜索该有的数据。

最后来看唯一索引怎么存储。假设创建一个唯一索引uniqueFoo: CREATE UNIQUE INDEX uniqueFoo ON test(stringVal)

不同于普通索引,唯一索引的key就只包含列数据了(不要主键列了),主键列的值这回被放到了value中。仍然是差运算,{主键列的值} - {索引列的值}。这个索引的key就是这样的:

Key Value
/test/uniqueFoo/”hello” /4
/test/uniqueFoo/”hello” /10

(注:显然这个表当前就没法建唯一索引)

我们在写入的时候用ConditionalPut来保证这个索引的唯一性。 (注:这里我的理解是,跟普通索引的差别是不加主键列来保证唯一性,那么就不能用前缀来保证唯一性吗?类似ConditionalPrefixPut(Prefix, Key,Value)这样的语义?)

原作者注

这种在KV数据上实现SQL的方法不是CockroachDB独创的。MysqlInnoDB, Sqlite4 一级其他数据库都有这样的设计。

译者注

关于文中,对二级索引,普通索引加入主键列,唯一索引将主键列放入value中的做法,似乎有待详细了解RocksDB的更多特性来理解?

Jan 7, 2018 - 皮一下ezorm

简介

(低能预警:这是一篇毫无技术内容的日常)

ezorm https://github.com/ezbuy/ezorm是我司内部的orm工具,日常用来生成mongo、mysql、sql server框架代码,省了很多事情。其中我用的最多的就是mongo代码的生成。这个工具的核心,一个是生成原理就是渲染模板文件,一个关于驱动,就是万年好死不死的mgo(连mongo官方工具集用go写的那套都是这个库,也没什么好办法)。

顺便吐槽一下,我之前也做过类似将mgo的常用代码规范化的事情,但是为啥不做orm工具咧,因为上一家公司的项目内部,有精确规划mongo连接的需求,需要对mgo的使用定制化,比如几条连接到主库,几条连接到从库等等,普适框架无法概括。但是做电商的业务,mongo没有位于热点数据的话,不会有这个需求。

ezorm的日常使用

ezorm生成的代码日常使用是这样的


users , err := ezorm.UserMgr.find(query)

user, err := ezorm.UserMgr.findOneBy{unique index}(unique index)


这样的代码,正常人都会这么写,其实恶心的还是golang

因为项目中代码,日常是这样的

users , err := ezorm.UserMgr.find(query)

for _, user := range users {
	retArry = append(retArray, doUser(user))
	...
}

for … range 写多了,不管你无不无聊,反正我觉得我想吐

golang没有泛型,但是有代码生成就可以

for … range 写吐这个想法,当然不止我有,翁伟同学(ezbuy CTO)早就有了,所以他对常用类型搞了一个库去支持消除for循环的append(库忘了在哪,反正我赶脚没啥用)。因为golang不支持泛型,所有用处有限。但是orm就可以嘛,反正你的代码是生成的,跟编译期模板展开也没啥区别。

比如这样ForEach,例子是转换int64数组到int32数组

一般golang程序员:


var i64Array []int64
var i32Array []int32
for _, i64 := range {
	i32Array = append(i32Array, int32(i64))
}

这个代码也没什么可说的。

吐过的golang程序员

type []int64 I64ArrayIter

func (this *I64ArrayIter) ForEach(f func (int,int64)) {
	for i, i64 := range this {
		f(i, i64)
	}
}

var i64Array []int64
var i32Array []int32

iter := I64ArrayIter(i64Array)

iter.ForEach(func (i int, i64 int64){
	i32Array = append(i32Array, int32(i64))
})

(是不是看上去酷炫了一点!)

(老师,还有更给力的吗?)

(有!老师学过FP!)

吐出过屎的golang程序员

type []int64 I64ArrayIter

func (this I64ArrayIter) ForEach(f func (int,int64)) {
	for i, i64 := range this {
		f(i, i64)
	}
}

type []int32 I32ArrayIter
func (this I32ArrayIter) Append(i32 int32) I32ArrayIter{
	this = append(this, i32)
}

var i64Array []int64
var i32Array []int32

iter64 := I64ArrayIter(i64Array)
iter32 := I32ArrayIter(i32Array)

iter.ForEach(func (i int, i64 int64){
	iter32.Append(int32(i64))
})

(这货是伪代码,不要跑;这货是伪代码,不要跑;这货是伪代码,不要跑)

(因为这样append是没有用的,我只是懒得写了)

(但是讲道理这样的代码是不是酷炫了很多捏?)

其实这样的写法,就是泛型展开而已,来看上面的抽象模板

type []{Type} {Type}ArrayIter

func (this {Type}ArrayIter) ForEach(f func (int, {Type})) {
	for i, one := range {Type}ArrayIter {
		f(i, one)
	}
}

func (this {Type}ArrayIter) Append(data {Type}) {Type}ArrayIter {
	{Type}ArrayIter = append({Type}ArrayIter, data)
	return {Type}ArrayIter
}

同理,这只是示意,这样append是没有任何卵用的。

那么皮一下ezorm

我在我的ezorm fork中加入了这么一个模板文件

内容就不贴了。

其中,能让append生效的定义是

type {.Name}Iter struct {
   data []*{.Name}
}

实现了我觉得比较有用的ForEach, Append,Filter,Len等FP常用的函数

生成的代码大约可以做到如下鬼畜:

blogs := []*Blog{
		BlogMgr.NewBlog(),
		BlogMgr.NewBlog(),
		BlogMgr.NewBlog(),
		BlogMgr.NewBlog(),
	}

	iter := NewBlogIter(blogs)
	
	filIndex := func(i int) bool { return i%2 == 0 }
	fileId := func(b *Blog) bool { return b.ID.Counter()%2 == 0 }
	foreach := func(i int, b *Blog) {}

	length := iter.Append(BlogMgr.NewBlog()).Foreach(foreach).
		Append(BlogMgr.NewBlog()).Foreach(foreach).
		Append(BlogMgr.NewBlog()).Foreach(foreach).
		Append(BlogMgr.NewBlog()).Foreach(foreach).
		Append(BlogMgr.NewBlog()).Foreach(foreach).
		FilterByIndex(filIndex).Foreach(foreach).
		Filter(fileId).Foreach(foreach).
		Len()

	t.Log(length)

(猫头妙啊.jpg)

用例文件在这里

总结

一直想给golang做一个支持泛型的脚本语言,但是由于各种原因(主要是不会)没做。然后发现在基于模板的代码生成工具里面,做到一定程序的泛型其实很容易哟。咻咪!(逃)。

当然这个patch,我是不会尝试合并到主分支的。个人趣味而已,对工具的价值没有任何用处。

Dec 28, 2017 - “远离枯燥的golang系列”之一次小题巨做的解耦

背景

事情是这样的,业务中有一块需求是根据一个url解析出他代表的电商平台是哪家。比如,通过域名就可以区分出淘宝(taobao),天猫(tmall),京东(jd),亚马逊(amazon)等。然后还要通过一些规则特征,识别出url代表是一个商品详情,还是一家该电商平台的店铺。业务的目的是要通过url获取(或者说抓取)它所对应的商品信息,单商品则抓商品,店铺页则抓所有店铺商品。单品好说,一般url中都有类似restful api的字段可解析,但店铺的话,又要先抓取一次店铺信息。 这块代码反复迭代,又没有经过大修,非常琐碎凌乱,最近,在几种平台的基础上,又要新增平台,我接手这块旧代码,深感力不从心,于是决心在失控之前部分重构。

这个坑的模样

第一步:

var type
if TaobaoShop {
	type = taobaoshop
} else if TabaoProduct {
	type = taobaoProduct
} else if TmallShop {
	....
}


总之第一步,先写好对应类型的判断函数,无论string包含还是正则表达式,不一而足,确定一个类型

第二步:

var productArray
if type & Product {
	if type & taobao {
		productArray = parseTaobao
	} else if type & tmall {
		productArray = parseTmall
	} else if .....
}

if type & Shop {
	if type & taobao {
		productArray = fetchTaobaoShop
	} else  if type & tmall {
		productArray = fetchTmallShop
	} else if  ...

}

这一步,从url中获取相应的待抓取商品

第三步:


if type & taobao {
	spiderType = spiderTaobao
} else if type tmall {
	spiderType = spiderTmall
} else if .....

第三步设置爬虫类型,幸好爬虫是一个统一的接口,只是传入的参数枚举值不同

加平台?坑爹呢!

这段代码要说这样看,其实还可以,只是无论解析还是抓取准备,都还有一些鸡毛代码;也就是说,这种代码的问题在于,我需要增加一种类型时,需要从头到脚改一遍,去增加他的if..else分支,费心费力,每次扩展都需要从头阅览。这还是我多次阅读老代码后梳理出来的简单流程,实际上原始代码更加复杂,“虬枝旁逸”。

第一波直觉:设计模式,工厂方法

工厂方法作为唯一一种我在实际编程中非常常用的设计模式,我第一时间就想到了它。模型也很合适,抽象一系列抓取、解析商品id的接口,从对url的类型解析生成一个具体的实例,流程代码由接口调用串联起来;这样增加平台时,后续代码只要增加实现的接口,将其放入静态工厂的定义中,就可以完成。

我的第一波代码变成了这样

// 第一步 urlMetaInfo 中包含了url的类型信息
urlMetaInfo = parseUrl(url)
// 第二步 根据类型信息生成对应的实现实例
var fetcher = NewFectherFactory(urlMetaInfo)

var productArray = fetcher.FetchProductId()

// 第三步
var spidertype = fetcher.GetSpiderType()
spider fetch ....

调用代码清爽了很多,表面上不再存在可怕的if..else阵列。。

工厂方法的背后,还是凌乱的定义与解析

这个时候pareUrl依然长这幅鬼样子

if type & taobao {
	spiderType = spiderTaobao
} else if type tmall {
	spiderType = spiderTmall
} else if .....

而这个时候NewFectherFactory使用的是一个全局的注册表

map = {
	taobao: taobaofetcher,
	amazon: amazonfetcher
	....
}

平台对应的抓取爬虫类型,依然是一个全局的map

map = {
	taobao: taobaoSpiderType,
	amazon: amazonSpiderType,
	....
}

进步的地方在于,我增加新的平台定义,不再需要动流程代码,但是推动这个背后代码的注册和定义,一个都不能少。

既然觉得定义来定义去太松散,不如组成一个平台模板结构

(ps: 后来我知道这个思路叫做SOP)

整理流程下来,我梳理了一个实现一个平台的抓取需要些什么。首先要有一个函数,帮我匹配url特征,告诉我这个url是不是这个平台的,并且是店铺还是商品url;其次有一个方法直接生成对应实例,不想再去哪里的map注册一个实例类型;最后在模板里定义好,这个平台对应的爬虫枚举值是哪个。 于是一个结构体:

type platformTemplater struct {
	typeDef
	spiderType
	newFetcherFunc
	parseFunc

}

其中typeDef表示平台,spiderType表示爬虫枚举值,两个函数指针,分别对应生成fetcher实例的函数与解析url的函数;其中解析url的parseFunc只要告诉我这个url他认识不认识就可以了。

于是假设要定义淘宝的抓取模板

typedef taobaoType
var taobaoplatform  = platformTemplate {
	typeDef : taobaoType
	spiderType: spiderType
	newFetcherFunc : func() fetcher {return new taobaofetcer}
	parseFunc : func() (bool, isShop)

}

一次实现, 一次定义,一次注册

一次实现:

实现具体平台下的id解析任务

一次定义:

定义平台枚举,定义好平台模板实例,包括 抓取实例的生成函数

一次注册:

将定义好的平台模板实例,写入实例数组,如

	
var platforms = []platformTemplate{
		taobaoplatform,
		tmalltplatform
	}
注册自动展开

生成实例函数与枚举值的对应关系表仍然存在,只是不需要定义后手动注册; 在初始化代码中,通过遍历platforms数组,填充了这些map。

实际上伪代码就是:

for p in platforms {
	fetcherMap[p.typeDef] = p.newFetcherFunc
	spiderMap[p.typeDef] = p.parseFunc
}

func NewFectherFactory(t typeDef) fecther {
	return fetcherMap[t]()  // 函数调用
}

func SpiderType(t typeDef) spiderType {
	return spiderMapp[t]
}

总结

面对流程复杂且可以抽象成标准过程的业务代码,可以先用接口实现过程抽象。然后如果因为接口/过程太多,要实现接口的接口或者定义较多,使得后续再扩展要做的事情比较多,容易出错或遗漏,就用类似SOP的思路,把要做的事情归纳到一个结构中。相当于给实现接口写了一个强类型的文档。开发者只需注册该结构,而不用关心其中的结构是如何被使用的。