背景
我们有部分业务逻辑比较复杂的线上项目是由Rails框架快速开发而来的,但其中的部分API(Restful)代码需要服务于几十万同时在线的物联网设备。随着设备量的不断增加, 对这部分代码的性能需求就越来越高。 在高峰时段, 业务所在服务器节点经常出现Passenger队列拥塞的情况, 非常影响服务质量 -- 不仅仅是这个高频API业务, 而且也会影响其他低频API的业务。 所以需要把这部分代码单独提取出来, 用更高效的方式来实现。
迁移前面对的问题:
- 需要拆分的高频API比较独立,并且基本是读数据库(极少写)
- 需要做到无缝迁移, 不能中断线上业务的运行
- API访问了大量的MySQL数据表,Rails的数据模型(Active Record)如何迁移
- 如何测试 - 测试代码的迁移,以及线上测试
为何选择Golang
运行时高效,低内存。拥有活跃的社区,以及非常多的三方开源库。也考虑过使用Openresty(nginx + lua),运行效率更高。 但相对于Golang来说, Openresty的社区不够活跃, 也找不到可以快速替换Rails的数据模型的方法,一句一句的拼SQL,开发效率极低,代码维护也比较困难。
迁移步骤
确定需要使用的开源软件
这一步非常重要。 如果没有开源代码的支撑,什么都自己实现,要做到快速开发上线,是极不现实的。由于大量开源软件的存在,当前大部分软件的开发的前提之一就是评估和测试各种可能要用到的开源软件。
从我们的要迁移的项目来说, 需要一个HTTP服务框架,数据层方面需要访问Redis以及Mysql数据库。
- HTTP服务框架
Golang自带的net/http包已经足够好,但是最终还是选择了使用Gin(github.com/gin-gonic/gin),和net/http一样的轻量高效。从架构上来看,Gin类似于Rails使用的Rack中间件。 - Redis客户端
github.com/garyburd/redigo/redis,长久以来一直使用,习惯了。 - Mysql Driver
github.com/go-sql-driver/mysql,也没什么可选的。
由于迁移工作量最大的部分在数据模型上面,所以需要一个数据模型框架(ORM)能够支撑快速的开发。清单包含了Golang当前比较流行的ORM框架。
在gorm,gorp,upper/db与sqlboiler中,最终选择了sqlboiler。初步选择sqlboiler的原因是其文档中有这么一句“While attempting to migrate a legacy Rails database, we realized how much ActiveRecord benefitted us in terms of development velocity. Coming over to the Go database/sql package after using ActiveRecord feels extremely repetitive, super long-winded and down-right boring.” 并且sqlboiler的文档有一份看起来还不错的benchmark报告。由此可见开源软件的文档有多么重要,丝毫不逊于代码本身,甚至比代码更重要,毕竟大部分人是看脸的。
生成数据模型
通过sqlboiler命令行工具可以非常容易的将现有Mysql的数据表转换为数据模型(通过模板生成访问数据表的GO代码),使用命令前需要配置~/.config/sqlboiler/sqlboiler.tom,让sqlboiler能够访问数据库和数据表。
sqlboiler -w tbl1,tbl2,tbl3,tbl4,tbl5 mysql
该命令生成一个models文件夹, 里面包含了访问tbl1,tbl2,tbl3,tbl4,tbl5这些表的代码,以及测试代码。现在我们已经拥有了一个的Mysql数据接入层了。
使用这些生成代码的风格如下:
db, err := sql.Open("mysql", "user:password@tcp(localhost:3306)/example_db?parseTime=true")
if err != nil {
panic(fmt.Sprintf("can not connect to mysql: %s", err))
}
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(3)
db.SetConnMaxLifetime(3 * time.Minute)
boil.SetDB(db)
users, err := models.UsersG().All()
users, err := models.UsersG(qm.Where("age > ?", 30), qm.Limit(5), qm.Offset(6)).All()
shop, err := models.ShopsG(qm.InnerJoin("router on router.shop_id = shops.id"), qm.Where("router.sn = ?", sn)).One()
更多细节可以参见文档。
补全数据模型
前面提到,访问数据库的代码是根据模板生成的,功能很单一。在组合复杂功能的时候需要对模型进行扩展, 其实迁移数据模型大部分的工作量都在这里。sqlboiler文档中建议了三种方法。个人比较喜欢第3种风格,示例如下:
package modext
type ShopExt struct {
M *models.Shop
ar *models.AuthenticationResource
sn string
}
func (s *ShopExt) BusinessHours() (string, string) {
if s.M == nil || !s.M.BusinessHours.Valid {
return "", ""
}
h := string(s.M.BusinessHours.String)
hs := strings.Split(h, "-")
if len(hs) == 2 {
return hs[0], hs[1]
}
return "", ""
}
...
对比下Rails的代码, 代码量明显增加(错误处理, 异常处理等), 通常一行Rails代码,用Golang重写需要十多行。
class Shop < ActiveRecord::Base
...
def start_business_hours
business_hours.to_s.split('-')[0].to_s
end
...
end
sqlboiler的缺点
- 只有显式设置外键的表,才会生成关联模型。我们现有Rails数据库,完全没有用到外键, 关联查询基本依靠手动的JOIN和多次查询,而不能像Rails可以设置belongs_to,has_one,has_many
- 不支持查询缓存,如果某些数据在一次请求中需要多次查询,需要显式将它的引用缓存起来, 比如上面例子中的 ar *models.AuthenticationResource,以减少数据库查询。
- 当前不支持在线对数据表做增加列的操作,我们自己打了个patch来解决这个问题。如果要使用这个补丁,可以将sqlboiler作为vendor package。
测试代码迁移
按Golang的风格写测试代码就可以了,利用Golang版本的fixtures可以快速迁移现有测试数据,但要注意它与Rails版本并不完全兼容。
线上测试和部署
对于迁移后的代码最好先做线上测试,再灰度上线,以确保旧代码和新代码的平稳过渡。如果前端部署了nginx作为API gateway,这个问题会非常容易解决。部署环境如下:
|--- node of old code
|-SLB1->|--- node of old code
| |--- node of old code
SLB ---> API GW(nginx)--|
| |--- node of new code
|-SLB2->|--- node of new code
|--- node of new code
首先,我们可以主动模拟客户端的请求同时访问SLB1和SLB2,完成AB测试。
小贴士:
对于JSON返回值的比较,可以使用reflect.DeepEqual,数组类型需要先排序再比较
比较直观的线上工具可以使用http://jsondiff.com。
其次, 可修改前端nginx的分发权重,做灰度上线。 比如, 设置10%的流量到新业务,如果一切如常,再逐步提高权重,直至全部流量导入新模块。
最后下线旧模块,完成切换。
迁移后的效果
本地压力测试显示,使用同样的redis和mysql配置,用10倍于Rails版本的流量对Golang版本进行压测, CPU占用约为Rails版本的40%, 内存占用仅为20%。
Golang版本上线后,如果处理每秒大约250的请求数(涉及大约10个关联表查询),总共耗费的CPU接近0.8个核, 内存100M,非常的环保。由于该功能从Rails服务中移除,剩余Rails代码在忙时也不会再报Passenger队列拥塞的告警。
结论
- 负载能力大幅提升,资源占用大幅下降,完全符合我们追求高效的目标。
- 首次迁移因为需要评估三方软件,需要写大量的Go代码来扩展数据模型,以及需要解决遇到的问题,所以比较耗费人力。
- 考虑到数据模型是完全可以重用的,后续只需再补充扩展就可以了。所以后期的维护成本并不会高,应该只是接近或略大于Rails项目的维护成本。