go-ycsb:一个 Go 的 YCSB 移植

YCSB 是一个非常出名的性能测试框架,我们可以非常方便的用它来对系统进行多维度的性能测试,本来我也准备使用它来对我们系统进行性能测试的,但在调研了一番之后,我决定直接用 Go 来完全移植一个。过年的时候就一直在干这件事情,于是就有了 go-ycsb

为什么需要 YCSB?

先来说说为什么我们需要 YCSB,对于一个系统来说,用户在试用之前,通常都会问『你的性能怎样?』,但其实这句话是非常不好回答的。所以通常业界都会用一些基准的性能测试工具来衡量。另一方面,一个系统,性能并不是只有一个维度,譬如 sharding 的系统可能随机 read 一个 key 非常快,但如果是顺序 scan 一批 key 性能就可能嗝屁了。再就是性能其实也跟数据的分布有关系,譬如有些数据就是热点,需要频繁操作,而大部分数据其实是冷数据。刚好 YCSB 都能很好的支持这些特性。

再来说说为什么需要 Go 的 YCSB,其实无非就是两个原因:

  1. 我不会 Java。虽然我个人对语言没啥偏爱,譬如我就一直搞不懂为啥很多写 C++ 的人不喜欢 Rust,但我个人对 Java 却实在提不起兴趣,所以到了现在,看到 Java 代码我就头大,自然不会想着自己去写 Java 相关的代码。
  2. 现在 TiKV 只有 Go 的 API,虽然我们 TiSpark 带了一个 Java TiKV client,但不支持写。为了能让 YCSB 直接测试 TiKV,我现在必须使用 Go,但我又不知道如何 Java 调用 Go 的代码,所以还不如用 Go 重写 YCSB 来的简单,反正不复杂。

Benchmark Tiers

YCSB 主要测试两层 – 性能和可扩展性。

对于性能来说,主要关注的是 Latency,当然,Latency 和 Throughput 是需要取舍的,在固定的硬件条件下,当我们逐渐增加请求的时候,因为 disk,CPU,network 等竞争,请求的 latency 是在增加的。所以我们需要知道的是需要多少机器才能满足用户 lantency 和 throughput 的需求。当然,需要机器越少,证明我们系统优化的越好。这里,YCSB 采用的是非常常见的 Wisconsin Sizeup 方法,固定硬件,增加测试并发压力,直到系统出现瓶颈过载。

而对于可扩展性来说,一个是按比例增加,将硬件,数据量和负载等比增加,正常情况下面 latency 是保持恒定的。另一个就是弹性加速,我们测试 N 个服务,然后在测试 N + 1 个服务,正常情况下面 latency 是要降低的。

Workload

这里来说说 YCSB 的 Workload,YCSB 提供了一个 Core workload,并且默认提供了很多的 workloads。每个 Workload 提供了一批混合读写的操作,数据量的大小,请求的分布等,所以用户可以依据不同的 workload 多维度的对系统进行测试。

操作主要包括:

  • Insert:插入一条新的记录
  • Update:更新一条记录的某一个或者所有 fields
  • Read:读取一条记录的某一个或者所有 fields
  • Scan:随机从一个 key 开始顺序扫描随机条记录

Distribution

在测试的时候,我们还需要根据不同的业务场景来模拟测试,这个就是通过 Distribution 来完成的。YCSB 提供了默认的几种 distribution:

  • Uniform:随机选择一个记录
  • Zipfian:根据 Zipfian 分布来选择记录。一些记录会比较热,而大部分记录会比较冷。
  • Latest:比较类似 Zipfian,但最近的新插入记录是在整个分布的开头。
  • Multinomial:根据概率指定,譬如,我们可以指定 0.95 的的 read 操作和 0.05 的 update 操作,然后 0 给 scan 和 insert,这样就是一个 read heavy workload。

《go-ycsb:一个 Go 的 YCSB 移植》

Zipfian 和 Latest 的区别在于使用 Latest,新插入的记录会变成最热的记录,而对于 Zipfian 来说,所有的记录仍然保持原来的冷热度。Latest 就比较适用于热点新闻,而对于 Zipfian,可能就比较适用于明星,对于他们的 profile,即使是几年前加入的,也会非常热。

至于 Distribution 到底是怎么实现的,可以详细参考 go-ycsb 的相关 generator 实现,后面如果有时间,也会详细的分析。

如何使用

《go-ycsb:一个 Go 的 YCSB 移植》

对于 YCSB 来说,是非常容易使用的,我们只需要选择好自己的 workload,先使用 load 导入数据,然后用 run 就能跑起来了。YCSB 提供常用的几种 workload:

WorkloadOperationsRecord selectionApplication example
A — Update heavyRead: 50%, Update: 50%Zipfian在用户的 session 里面 存储和访问最近的操作
B — Read heavyRead: 95%, Update: 5%Zipfian图片标记,打标记是 update,但多数时候是 read
C — Read onlyRead: 100%Zipfian用户 profile cache
D — Read latestRead: 95%, Insert: 5%Latest用户最近的状态更新
E — Short rangeScan: 95%, Insert: 5%Zipfian / Uniform不同主题帖子浏览

这里以 Workload A 为例,我们使用 MySQL,导入相关的数据:

./bin/go-ycsb load mysql -P workload/workloada -p mysql.host=127.0.0.1 -p mysql.port=3306 -p mysql.db=test 

上面,我们使用 mysql 这个 Database,指定了一个 Workload A,然后传入了 MySQL 相关的参数,先用 load 导入了 1000 行数据。

然后我们开始执行:

./bin/go-ycsb run mysql -P workload/workloada -p mysql.host=127.0.0.1 -p mysql.port=3306 -p mysql.db=test 

如何测试自己的 Database

如果要在 go-ycsb 测试自己的 Database,也非常容易。只要实现 DB 和 DBCreator 的 interface 就可以了。首先,我们要实现自己的 Database,接口定义如下

type DB interface {
    Close() error
    InitThread(ctx context.Context, threadID int, threadCount int) context.Context
    CleanupThread(ctx context.Context)
    Read(ctx context.Context, table string, key string, fields []string) (map[string][]byte, error)
    Scan(ctx context.Context, table string, startKey string, count int, fields []string) ([]map[string][]byte, error)
    Update(ctx context.Context, table string, key string, values map[string][]byte) error
    Insert(ctx context.Context, table string, key string, values map[string][]byte) error
    Delete(ctx context.Context, table string, key string) error
}

对于 Read,Update, Insert,Scan,Delete 等函数,非常直观,这里不过多解释。这里需要关注 InitThread 和 CleanupThread,对于 YCSB 来说,DB 是多线程安全的,我们会启动多个 thread (Go 里面就是 goroutine)同时对该 DB 进行操作,但有些时候,我们需要每个 thread 上面都有该 DB 的 local thread 变量,所以在 thread 开始的时候,我们会调用 InitThread,而结束的时候会 CleanupThread。

以 basic 为例,

type contextKey string
const stateKey = contextKey("basicDB")

type basicState struct {
    r *rand.Rand
    buf *bytes.Buffer
}

func (db *basicDB) InitThread(ctx context.Context, _ int, _ int) context.Context {
    state := new(basicState)
    state.r = rand.New(rand.NewSource(time.Now().UnixNano()))
    state.buf = new(bytes.Buffer)

    return context.WithValue(ctx, stateKey, state)
}
func (db *basicDB) Read(ctx context.Context, table string, key string, fields []string) (map[string][]byte, error) {
    state := ctx.Value(stateKey).(*basicState)
...
}

它对于每个 thread,创建了一个 basicState,并且挂在到 context 上面,这样,后面我们就可以通过 context Value 得到这个 basicState 了。为什么要做这个事情了,当初设计的时候主要是为了避免全局使用 Go 的 rand 函数,我们这边已经无数次碰到全局使用 rand 造成的性能问题了,毕竟里面有 Mutex,所以最好就是每个 thread 单独的 rand。

然后我们要实现一个 DBCreator,接口定义如下:

type DBCreator interface {
    Create(p *properties.Properties) (DB, error)
}

这个接口很简单,就是根据当前的配置,创建对应的 DB,然后我们需要将自己的 Creator 注册给 YCSB,并制定一个唯一的名字,譬如对于 basic 这个 Database,我们在 init 函数里面直接全局注册:

func init() {
    ycsb.RegisterDBCreator("basic", basicDBCreator{})
}

具体可以参考现有的一些例子,然后注册给 YCSB,譬如 basic DB 就直接在 import 里面注册:

    _ "github.com/pingcap/go-ycsb/db/basic"

小结

现在,在我们项目的 issue 里面,已经有看到有些用户将 go-ycsb 用来测试我们的系统,而后面,我们会用 go-ycsb 多维度的对整个系统进行测试,作为我们后面优化的一个性能基准指标。但是,现在 go-ycsb 还是有很多需要完善的,譬如在统计信息上面,现在就比较粗糙,而且还不能支持 export 到外面,让外面生成图表等。

如果你对性能测试工具感兴趣,欢迎联系我,一起来完善,我的邮箱 tl@pingcap.com

点赞