随着 Golang 在后端领域越来越流行,有越来越多的公司选择 Golang 作为主力开发语言。本次 GopherChina Beijing 2016 大会上,看到 Golang 在各家公司从人工智能到自动运维,从 Web 应用到基础架构都发挥着越来越多的作用。可以说 Golang 在这几年间,获得了长足的进步。
PingCAP 是一家由几名 Golang 粉丝创建的数据库公司。在我们的日常工作中,除了对性能有苛刻要求的最底层存储引擎外,大部分都是使用 Golang,算是 Golang 的重度用户。我们从 Golang 语言以及社区中收益颇多,TiDB 在短短半年的时间内,从无到有,从默默无闻到广泛关注,已经成长为 Golang 社区的明星项目。我们在这个过程中也积累了不少工程实践经验,这里想和大家分享一下。
Why Golang?
网上已经有无数的文章描述 Golang 的优点,所以没有必要一一列举。我们选择 Golang 并不是因为跟风或者是我们是 Golang 的粉丝,而是经过理性的分析和讨论,认为 Golang 最适合我们的业务场景。
开发效率高
作为技术创业公司,我们期望维护一个精英技术团队,人数不多,但是交付速度快、代码质量高。这样我们需要一门高效的语言,Golang 在这方面令我们非常满意。Golang 易于上手,有过其他语言经验的人,很容易转到 Golang。超强的表达能力、完备的标准库以及大量成熟的第三方库,使得我们可以专心于核心业务。自动内存管理,避免了 c/c++ 中的指针乱飞的情况,易于写出正确的程序。从15年6月写下第一行代码开始,到15年9月我们已经完成第一版的产品,并且达到可开源的要求。开源后我们从社区中获得了不少有价值的反馈以及大量的第三方 Contributor。从 GopherChina 大会上,我们注意到除了大公司处理海量并发时会采用 Golang 外,越来越多的创业型公司也在使用 Golang,我想这和 Golang 的易于上手、开发效率高有很大关系。
并发友好
对于一个分布式数据库,相比较延迟而言吞吐量是一个更关键指标。当然这里并不是说延迟可以无限大,而是在保证延迟相对较低的情况下,尽可能的提高吞吐。TiDB 的设计目标是能响应海量的用户请求,我们期望有一种低成本的方式同时处理多个用户连接。同时数据库内部的一些逻辑也要求在处理用户请求的同时,还有大量的后台线程在做自己的工作。
Golang 在这方面有天然的优势,甚至可以说 Golang 就是一门为了并发而生语言。goroutine 和 channel 使得编写并发的程序变得相当容易且自然,很多情况下完全不需要考虑锁机制以及由此带来的各种问题。单个 Go 应用也能有效的利用多个 CPU 核,并行执行的性能好。与此同时,Golang 运行的性能虽然不如 C/C++,但是还没有数量级的差别,可以满足对延迟的要求。
部署简单
我们把系统部署简单易用作为 TiDB 的一个重要的设计目标。我想部署和维护过其他分布式系统(比如 Hbase)的同学,对这一点一定深有感触。
Golang 编译生成的是一个静态链接的可执行文件,除了 glibc 外没有其他外部依赖。这让部署变得很方便。目标机器上只需要一个基础的系统和必要的管理、监控工具,完全不需要操心应用所需的各种包、库的依赖关系,大大减轻了维护的负担。
Good Practice
在使用 Golang 的过程中,我们也获得了一些很好的实践经验,包括语言使用上的,以及工程上的经验。
重视单元测试
Golang 带有一个简单好用的单元测试框架,包括功能测试和性能测试。每个模块都能以非常简单的方式进行测试,以验证功能的正确性,并且避免后续被别人改错。在做 Code Review 时,我们强制要求所有的改动必须有 test case,否则 PR 会被拒绝。对于性能关键的模块,我们会加上 bench test,每次改动后会观察性能的变化。
重视 CI
数据库是一个复杂的系统,单靠单元测试无法保证系统的正确性,我们需要大量的集成测试。受益于 MySQL 的生态,我们可以获得大量可以直接用的测试资源,包括各种 ORM 自带的测试、MySQL 自带的测试、各种 MySQL 应用的测试。
TiDB 除了在提交 PR 时会做最基本的测试之外,还有十几个集成测试随时待命。我们在内部搭建了 jenkins 系统,每次代码有变动,都会自动构建这十几个测试集。如果有任何一个 Fail 了,相关人员必须停下手中的工作,马上去 Fix。另外 jenkins 也可以作为性能监测工具,每次提交后都会记录下运行时间,可以和历史记录中的时间作比较,如果运行时间突然变长,需要立即解决。
重视代码质量
代码是技术型公司最重要的产品,而且我们又是一家以开源方式运作的技术公司,代码的质量相当于公司的招牌,我们在这方面花了很大的力气。
我们制定了严格的 Code Review 制度。任何 PR 都需要有至少两个maintainer 看过,并且认为改动 OK,给出 LGTM 后,才能合并进主干。这两个做 review 工作的人要保证看过、理解每一行代码,并且要到能独立修改。否则提交 PR 的人需要给 reviewer 进行详细的介绍,直到讲懂为止。
另外我们还利用一些第三方工具来检测代码质量。比如 GoReportCard,这个工具会分析代码中的潜在问题,如赋值过的变量在作用域内没有被使用、函数过长、switch 分支过长、typo。项目在这里面的排名在一定程度上反映了代码的质量。目前 TiDB 的代码质量被评为A+级别。
一切自动化
Go 自带完善的工具链,大大提高了团队协作的一致性。比如 gofmt 自动排版 Go 代码,很大程度上杜绝了不同人写的代码排版风格不一致的问题。把编辑器配置成在编辑存档的时候自动运行 gofmt,这样在编写代码的时候可以随意摆放位置,存档的时候自动变成正确排版的代码。此外还有 golint, govet 等非常有用的工具。TiDB 将 golint、govet 的检查加入 Makefile,每次构建时,都会自动测试,这样可以防止一些低级的错误被提交。
善于利用 Pprof
在系统性能调优或者是死锁监测方面,一个 Inspector 机制能极大的提高效率。幸运的是 Golang 自带 profile 工具,简单的几行代码就能方便地提供一个 HTTP 界面,展现当前系统的所有状态。目前在开发过程中,我们会默认打开 pprof,这个机制也不止一次地帮助我们发现系统中的问题。
那些年我们踩过的坑
Golang 是一门很好的语言,但并不是一门完美无缺的语言,我们在实践中也踩过不少坑。
interface{} 的性能问题
数据库中有大量的数据类型,所以我们需要一个统一的结构来处理所有的类型。我们最初的方案是选择 interface{},这也是 Golang 中比较自由的选择。所有的数据类型都可以赋值给 interface{},所有的数据类型相关的函数也都以 interface{} 作为参数,然后在内部用 switch 语句判断类型,这样程序写起来比较简单。但是很快我们发现大量的 type assert 拖慢了我们的程序,比如下面这段代码:
var val interface{}
val = int64(100)
经过我们测试,把一个整数赋值给一个 interface{} 类型的变量,会触发一次内存分配,通常要耗时几十到上百纳秒。在运行 SQL 语句时,会有大量的类似操作,对性能的损耗严重。为了解决这个问题,我们调研了其他数据库的解决方案,最终采用自定的数据包装类型 Datum 取代 interface{},这个 Datum 需要能存放各种类型, 实现 value 对 value 赋值。同时为了减少空间占用, Datum 内部的属性会在多种数据类型之间重用。上面的代码重构后变成:
var d Datum
d.SetInt64(100)
重构后,在我们的 bench 结果中,表达式计算相关操作的性能,提升 10 倍以上。
包依赖问题
Golang 的包依赖问题一直被人诟病,可以说到目前为止,也没有完美的解决方案。
Golang 中隐藏的一些 Bug
相比 C/C++/Java/Python 等语言,Golang 算是一门年轻的语言,还是存在一些 bug。上周我们遇到一个诡异的问题,调用 atomic.AddInt64 时,在64位系统上 OK, 但是在 i386 系统上,会导致 crash。我们通过内部的 CI 发现问题后,经过研究发现这是 Golang 的一个 bug,对于 32 位系统,需要自己来保证内存对齐。
Conclusion
相比 C++/Java/Python 等语言,Golang 不支持许多高级的语言特性,但从工程的角度讲,Go 的设计是非常优秀的:规范足够简单灵活,有其他语言基础的程序员都能迅速上手。
TiDB 设计之初,我们定了一个原则: Make it run. Make it right. Make it fast. Golang 很好的满足了我们的原则。高效的开发使得我们很快能做出能 run 的产品,自动的 GC 以及内置的测试框架有利于我们写出正确的程序,方便的 Profile 工具帮助我们进行系统调优。
除此之外,Golang 还有一个成熟友好的社区,Gopher 们在从社区获得收益的同时,很愿意向社区做贡献,大量高质量的第三方库就是最明显的体现。在平时开发遇到 Golang 相关的问题时,很容易借鉴到别人的经验,节省了我们大量的时间。