在本文中,我们对两个分别用 Go 语言和 Java 语言开发的基本应用(app)进行对比测试,去看哪一个消耗的 CPU/memory 资源比较少。
当 Go 语言在2009年11月首次出现时,我们并没有听说过它多少。我们首次互动发生在2012年,当时谷歌正式官方发布了 Go version 1。我们的团队决定去说服我们的客户在他们的项目中使用 Go 语言,但这是个难以推销的事儿,客户拒接了我们的推荐(大部分原因是他们的支撑团队缺乏 Go 语言的相关知识)。
然而自那之后,形势已经发生了很大的变化: Docker 广泛流行、微服务架构兴起,Go 作为一种编程语言更加成熟(在没有多少改变它语法的情况下)。所以,我兄弟和我决定换个角度审视 Go,并重新开始我们的旅程。我们开始阅读官方文档、教程、关于 Go 的博客和文章,尤其是那些包含作者自身从 Java 转换到 Go 的经验分享或者二者之间的对比内容的博客和文章。而在那时,我们使用 Java 语言已经超过15年了。
我们偶然遇到一篇文章比较了在实施微服务中,Java 和 Go 哪一个在相同的硬件条件下可以服务更多的用户。我们对 Java 有很长的使用经验,知晓它的强大之处和弱点,虽然对 Go 只有几个月的使用经验,但已经知道它的优点(编译型语言、用于实现并发的 goroutines 并不是传统的多线程),我们希望获得 Go 在更小的硬件资源条件下(CPU/memory 消耗)更为确切的结果。
我们惊讶地发现,当并发的用户数量低于 2k 时,Go 性能有所落后。既然我们已经决定在学习 Go 上花费时间,调查出导致这样结果的原因对我们而言就很重要。在本文中,我们会解释我们如何调查这个问题,并如何测试我们的改变(指重新设计实验)。
初始实验的简要描述
作者创建了有3个 API 接口的服务(service):
- POST /client/new/{balance} — 创建一个账户余额为零的新客户端。
- POST /transaction — 将钱从一个账户转移到另外一个账户。
- GET /client/{id}/balance — 返回指定账户的余额。
分别用 Java 和 Go 实现上述接口。作者采用 PostgreSQL 实现数据的持久化。为了测试这些服务,作者创建了一个 jmeter scenario,使用从 1k 到 10k 不同的并发用户数量测试组来运行它。
所有的实验在 AWS 上运行。
Java |
Go |
|||
用户数量 |
Response time (sec) |
Errors (%) |
Response time (sec) |
Errors (%) |
… |
… |
… |
… |
… |
4k |
5.96 |
2.63% |
14.20 |
6.62% |
… |
… |
… |
… |
… |
10k |
42.59 |
16.03% |
46.50 |
39.30% |
长话短说
问题的根本原因是 Postgres 的可用连接数的限制(默认是 100 个连接),以及对 SQL DB 对象的不恰当使用。在解决掉这些问题后,所有的服务表现出相似的结果,唯一的不同的是 Java 更大的 CPU 和 memory 消耗(Java 中所有东西都更大)。
详细分析
我们决定去分析为什么 Go 版本服务的错误率如此之高。为此,我们在原始代码中添加了日志,并亲自运行负载测试。分析日志之后,我们注意到所有的错误率要归因于与数据库开放连接有关的一个异常。
仔细审查代码之后,我们首先注意到的是,针对每一次 API 调用,都会有一个新 sql.DB 被创建。
func GetBalance(client_id int) int {
db, err := newDb()
...
}
func newDb() (*sql.DB, error) {
dbinfo := fmt.Sprintf("host=%s user=%s password=%s dbname=%s sslmode=disable",
DB_HOST, DB_USER, DB_PASSWORD, DB_NAME)
db, err := sql.Open("postgres", dbinfo)
...
return db, err
}
你首先需要知道的是,一个 sql.DB 并不是一个数据库连接。官方文档对其有如下说明
DB 是一个数据库句柄,代表了一个包含零个或多个连接的连接池。多个 goroutine 并发地使用它是安全的。sql 包会自动创建和释放连接;它同样维护一个包含闲置连接的连接池。返回的 DB 对于多个 goroutine 的并发是安全的,并各自维护自己的空闲连接池。因此,Open 函数应到只被调用一次。没有必要去关闭一个 DB
如果一个 app 将连接释放回连接池失败,会导致 db.SQL 去打开很多的其他连接,可能会耗尽资源(太多的连接,太多的打开文件句柄,缺乏足够可用的网络端口,等等):
func GetBalance(client_id int) int { ... err = db.QueryRow(...) checkErr(err) db.Close() ... } func checkErr(err error) { if err != nil { fmt.Println(err); panic(err) } }
如果 QueryRow 由于一个错误而失败,它会在调用checkErr时触发 panic,db.Close 也就不会被调用了.
Java 版本的服务也没有使用连接池,但是由于打开连接是放在try代码块中的,它至少没有泄露连接:
public Balance getBalance(Integer clientId) {
Balance balance = new Balance();
try (Connection con = getConnection();
PreparedStatement ps = getBalanceStatement(con, clientId);
ResultSet rs = ps.executeQuery()) {
...
}
我们也注意到另外一个问题,在 app 中,goroutine 的数量和 DB 连接数量并没有受到限制,但是 Postgres DB 的连接数是有限制的。作者没有意识到要去修改 Postgres DB 的设置,而连接的默认限制是 100。这意味着对于 10k 的并发请求测试,由于资源有限,Java(基于多线程)和 Go(基于goroutine)并没有经历充分的对比测试。
经过上述分析之后,我们开始分析 JMeter test scenario。每运行一次,会创建一个新用户,并把 id 为1的用户的钱转移到 id 为2的用户账上,并返回 id 为1用户的余额。一个新创建的用户会被忽略掉。针对相同 id 的用户,对 transaction 表进行插入和读取操作可能也会降低性能,尤其是在每次运行后,DB 并不一定清除。
我们最后注意到的是,用于运行 Java 和 Go 服务的实例类型。T2 具备很好的性价比,但是由于它的突发性能特征,对于性能测试而言并不是最好的选择。你并不能保证每次运行的结果都会一样。
接下来…
再跑测试之前,我们决定处理掉我们所发现的问题。我们的目标并不是写出完美的代码,而是去解决掉相关问题。我们复制了作者的代码仓库,并进行了相关修改。
对于 Go 版本,我们将 sql.DB 的创建挪到应用启动的地方,并在应用关闭时关闭它。当 DB 操作失败时,也不让 panicking 被触发,而是让应用返回给客户端一个附带错误信息的错误码 500。我们只保留 sql.DB 创建时的 panicking。应用开始运行时并没有去检查 DB 是否正在运行。此外,我们通过使用一个环境变量去设置数据库的连接数量限制。
对于 Java 版本,我们添加了连接池。我们使用了 Hikary 连接池,因为它比较轻量、性能较好
对于两个版本,我们都修改了 Dockerfile,使用基于 alpine 的镜像进行级联构建。这并不影响性能,但是会让最终的镜像显著更小。
你可以检查修改后的代码:
我们同样修改了 JMeter test scenario。新的 test scenario 在每次运行时,会创建两个新 user (拥有前述的账户余额)。之后,它会发出一个 get 请求,获取每个用户的余额,加以确认。再之后,它会将其中一个用户的钱转移到另外一个上。最后,它会再次发出 get 请求,获取每个用户的余额,检查在转移钱之后,他们的账户余额是否正确。
新实验
为了测试修改之后的两个版本服务的性能,我们从 AWS 选择了如下实例:
- Service 本身 (Java 和 Go 版本)使用 m5.large.
- Jmter 使用 m5.xlarge.
- PosgreSQL 使用 c5d.xlarge.
实验中的所有元素(Java 和 Go 的服务,JMeter,和 PostgreSQL)都运行在 AWS ECS 中的 Docker 容器中。对于 PostgreSQL 容器,我们创建了卷,用户存储所有数据,避免用一个可写的容器(会影响 DB 性能,尤其在重负载的情况下)。
每个性能测试之后,我们重新开始 PostgreSQL,以避免上一次运行的任何影响。
我们选用 m5.large 来运行服务,是由于它在计算能力、内存和网络资源方面的均衡性。对于 PostgreSQL,我们选择了 c5d.2xlarge,考虑到它优化后的计算能力,以及具备基于 NVMe 的 SSD 块级存储能力。
下面,你可以看到对于 Go 版本服务,当并发用户数是 4k 时候,JMeter 的输出:
这里是对于 Java 版本服务,当并发用户数是 4k 的时候,JMeter 输出:
对于 Go 版本服务,当并发用户数是 10k 时候,JMeter 的输出:
对于 Java 版本服务,当并发用户数是 10k 时候,JMeter 的输出:
你可以在这里找到 Go 版本的 JMeter 结果报告,以及这里找到 Java 版本的 JMeter 结果报告。
Go 版本服务的 CPU/memory 占用记录
总结
在这篇文章中,我们不会去做任何结论或者挑选一个胜者。你需要自行决定你是否想转换到一个新编程语言,并是否投入巨大努力成为一个专家。已经有很多的文章去讨论 Java 和 Go 各自的优劣利弊。总而言之,任何能够解决你问题的编程语言就是合适的编程语言。
原文链接:https://dzone.com/articles/java-vs-go-microservices-load-testing-rematch
登录后评论
立即登录 注册