Tubi 为用户免费提供成千上万部优质的电影和电视剧,从海量的内容中推荐用户感兴趣的内容是我们的核心用户体验,为此,工程团队开发了一个后端服务 Predictor Service 来实时为用户推荐内容。
Tubi 首页,所有的内容(横向和纵向)都是个性化的
离线推荐服务从存储中获取预先计算好的推荐结果,实时推荐服务则需要实时获取特征并计算推荐结果,会消耗更多 CPU 和内存资源,单个请求的延迟也更高。为了不影响用户体验,我们对实时推荐服务做了一系列性能测试和优化,这篇文章介绍我们是如何对实时推荐服务做性能测试,使它能满足我们对吞吐量(throughput)和延迟(latency)的要求的。
实时推荐服务和离线推荐服务,离线数据处理之间的关系
Microbenchmark
在开始实现实时推荐服务之前,我们想知道每次运行推荐模型大概需要花费多少时间,如果每次运行推荐模型都需要几百毫秒是不能接受的,因为这意味着用户可感知的延迟 [1]。Microbenchmark 可以用来衡量一小段特定代码的性能,通过 microbenchmark 我们能够很快地验证技术方案是否可行。
下面是用 ScalaMeter [2]编写的 microbenchmark:
val sizes = Gen.range("size")(1000, 10000, 1000)
val input = for { size <- sizes } yield genRows(size)
performance of "RealTimeModelServing" in {
measure method "predict" in {
using(input) in { rows =>
rows.map(predictor.predict)
}
}
}
这段代码非常简单,我们随机生成 1,000 到 10,000 行的数据作为输入(也就是需要被排序的电影和电视剧数量),然后运行推荐模型。
下面是 microbenchmark 的输出:
Sampling 4 measurements in separate JVM invocation 8 - RealTimeModelServing.predict, Test-0.
Finished test set for RealTimeModelServing.predict, curve Test-0
:::Summary of regression test results - Accepter():::
Test group: RealTimeModelServing.predict
- RealTimeModelServing.predict.Test-0 measurements:
- at size -> 1000: passed
(mean = 23.57 ms, ci = <20.66 ms, 26.47 ms>, significance = 1.0E-10)
- at size -> 2000: passed
(mean = 44.48 ms, ci = <40.30 ms, 48.66 ms>, significance = 1.0E-10)
- at size -> 3000: passed
...
- at size -> 9000: passed
(mean = 195.98 ms, ci = <185.69 ms, 206.27 ms>, significance = 1.0E-10)
- at size -> 10000: passed
(mean = 222.22 ms, ci = <197.03 ms, 247.41 ms>, significance = 1.0E-10)
Microbenchmark 的结果表明,1,000 行数据平均需要 23.57ms 来计算推荐结果。这个结果在可接受的范围,我们可以开始实现推荐服务并进行压力测试了。
压力测试(Load Testing)
当我们编写完推荐服务的原型后,我们需要衡量这个服务接口的整体性能,整个过程包括接受、校验、解析并最终返回结果。
压力测试模拟多个用户同时访问服务,来衡量在压力下服务的响应速度。
指标
在压力测试中我们通常用三个指标来衡量服务的性能:
-
延迟(latency)衡量服务多快返回结果给客户端,通常以毫秒作为单位。延迟通常用百分位(percentiles)而不是平均数(average)衡量。第 99 个百分位是 100ms 意味着 99% 的请求在 100ms 内返回了,这也简写成 p99。
-
吞吐量(throughput)衡量服务在单位时间可以处理的请求量,通常以每秒处理的请求量衡量(request per second RPS 或 query per second QPS)。
-
错误率(error rate)是请求失败的百分比,用来衡量服务在压力下能否正常工作。
这些指标相互之间是有关联的,通常在没有达到服务瓶颈时,更多的请求压力意味着更高的延迟、吞吐量和错误率。
除了延迟、吞吐量和错误率外,我们还需要观察在压力测试时机器的资源使用率(CPU,memory 等),理想情况下,我们希望编写一个用最少机器满足吞吐量和延迟需求的服务。
流程
在了解了上面几个指标后,通常压力测试会按照下面步骤执行:
-
定义期望的服务延迟、吞吐量和错误率指标
-
发送流量到目标服务来预热(warm-up)
-
发送测试流量到目标服务,记录延迟、吞吐量、错误率和机器资源指标
-
逐步增加测试流量
-
通过记录的指标,判断目标服务是否满足 #1 定义的期望,应该使用多少机器和什么类型的机器
测试
网络上有很多开源的用于压力测试的工具,你可以从 awesome-http-benchmark [3] 仓库找到适合自己需求的压力测试工具。我们使用 wrk2 [4] 来进行压力测试。wrk2 是 wrk [5] 的修改版本,它增加了 --rate 参数来指定测试流量的大小。Will Glozer 在 github.com/wg/wrk/issu… 描述了这两个工具的区别:
the naming of “wrk2” is unfortunate, it’s evolved into a very different tool based on generating load at a constant rate and can only record latencies at millisecond granularity. Whereas wrk generates load as fast as possible and tracks latency at the microsecond level.
能够以恒定速度产生测试流量能够避免 coordinated omission,coordinated omission 说的是在压力测试过程中,压力测试工具和被测试的服务无意间忽略高延迟的请求。Gil Tene 的视频 How NOT to Measure Latency [6] 有关于 coordinated omission 更详细的介绍。
在我们的使用场景中,实时推荐服务期望吞吐量不小于每秒处理 400 个请求,p99 小于 200ms,错误率小于 0.1%。我们把编写的服务部署到一台 AWS EC2 c5.2xlarge 实例上,下面是以 100 req/s 流量测试服务的结果:
$ wrk --rate 100 --duration 5m --latency --u\_latency --threads 8 --connections 16 --script wrk.lua http://predictor-02
.node.tubi:8080/predictor/v1/get-ranking
...
Latency Distribution (HdrHistogram - Recorded Latency)
50.000% 74.43ms
75.000% 87.42ms
90.000% 95.36ms
99.000% 108.42ms
99.900% 120.77ms
99.990% 126.01ms
99.999% 129.21ms
100.000% 129.21ms
...
Latency Distribution (HdrHistogram - Uncorrected Latency (measured without taking delayed starts into account))
50.000% 73.73ms
75.000% 86.78ms
90.000% 94.65ms
99.000% 107.65ms
99.900% 120.45ms
99.990% 125.76ms
99.999% 128.83ms
100.000% 128.83ms
...
30000 requests in 5.00m, 199.19MB read
Non-2xx or 3xx responses: 4055
Requests/sec: 99.99
Transfer/sec: 679.83KB
当以 150 req/s 的流量测试时:
$ wrk --rate 150 --duration 5m --latency --u\_latency --threads 8 --connections 16 --script wrk.lua http://predictor-02
.node.tubi:8080/predictor/v1/get-ranking
...
Latency Distribution (HdrHistogram - Recorded Latency)
50.000% 73.73ms
75.000% 87.49ms
90.000% 95.81ms
99.000% 111.42ms
99.900% 130.43ms
99.990% 152.19ms
99.999% 184.70ms
100.000% 184.70ms
...
Latency Distribution (HdrHistogram - Uncorrected Latency (measured without taking delayed starts into account))
50.000% 72.96ms
75.000% 86.65ms
90.000% 94.97ms
99.000% 110.33ms
99.900% 129.28ms
99.990% 151.29ms
99.999% 183.81ms
100.000% 183.81ms
...
45008 requests in 5.00m, 297.60MB read
Non-2xx or 3xx responses: 6207
Requests/sec: 150.02
Transfer/sec: 0.99MB
当以 200 req/s 的流量测试时:
$ wrk --rate 200 --duration 5m --latency --u\_latency --threads 8 --connections 16 --script wrk.lua http://predictor-02
.node.tubi:8080/predictor/v1/get-ranking
...
Latency Distribution (HdrHistogram - Recorded Latency)
50.000% 6.20s
75.000% 8.45s
90.000% 9.91s
99.000% 11.49s
99.900% 11.78s
99.990% 11.95s
99.999% 11.98s
100.000% 11.99s
...
Latency Distribution (HdrHistogram - Uncorrected Latency (measured without taking delayed starts into account))
50.000% 85.63ms
75.000% 98.88ms
90.000% 111.49ms
99.000% 136.57ms
99.900% 156.29ms
99.990% 172.67ms
99.999% 180.10ms
100.000% 183.42ms
...
57662 requests in 5.00m, 380.75MB read
Non-2xx or 3xx responses: 8043
Requests/sec: 192.20
Transfer/sec: 1.27MB
结果显示面对 200 req/s 的流量时,p99 增长了很多。因为实时推荐服务是 CPU 密集型服务,我们用 USE 方法 [7]和 vmstat 1 验证 CPU 处于饱和状态(saturation)是服务不能处理测试流量的原因。
多机测试
我们编写的实时推荐服务之间不共享状态,可以很方便的进行横向扩展。把测试服务增加到四台机器后,我们需要修改 wrk2 脚本将流量均匀分布到多台机器上:
\-- usage \`wrk --latency -c 8 -t 4 -d 1m -R 300 -s wrk.lua http://predictor.service.tubi:8080/predictor/v1/get-ranking\`
-- note the number of thread should equal to or larger than the number of host
local addrs = wrk.lookup(wrk.host, wrk.port or "http")
for i = #addrs, 1, -1 do
if not wrk.connect(addrs\[i\]) then
table.remove(addrs, i)
end
end
local index = 0
function setup(thread)
index = index + 1
thread.addr = addrs\[index\]
end
function init(args)
local msg = "thread addr: %s"
print(msg:format(wrk.thread.addr))
end
下面的压力测试结果显示,在横向扩展后测试服务可以轻松处理 400 req/s:
$ wrk --rate 200 --duration 5m --latency --threads 8 --connections 16 --script wrk.lua http://
predictor.service.tubi:8080/predictor/v1/get-ranking
...
Latency Distribution (HdrHistogram - Recorded Latency)
50.000% 35.94ms
75.000% 39.23ms
90.000% 45.02ms
99.000% 58.56ms
99.900% 74.05ms
99.990% 89.02ms
99.999% 96.13ms
100.000% 161.02ms
...
59999 requests in 5.00m, 396.19MB read
Non-2xx or 3xx responses: 8363
Requests/sec: 200.04
Transfer/sec: 1.32MB
$ wrk --rate 400 --duration 5m --latency --u\_latency --threads 8 --connections 16 --script wrk.lua http://
predictor.service.tubi:8080/predictor/v1/get-ranking
...
Latency Distribution (HdrHistogram - Recorded Latency)
50.000% 38.14ms
75.000% 43.04ms
90.000% 49.09ms
99.000% 66.75ms
99.900% 87.93ms
99.990% 105.34ms
99.999% 135.42ms
100.000% 142.34ms
...
119980 requests in 5.00m, 792.34MB read
Non-2xx or 3xx responses: 16592
Requests/sec: 399.99
Transfer/sec: 2.64MB
Autobench2
为了简化整个压力测试的流程,我们编写了一个简单的 python 脚本,这个脚本处理了包括发送预热流量,逐步增加测试流量并记录各种指标,并最终生成可视化的结果来展示测试流量和吞吐量、延迟之间的关系。
$ autobench --verbose --connection 8 --thread 4 --duration 1m \\
--script wrk.lua --warmup\_duration 1m --low\_rate 10 \\
--high\_rate 20 --rate\_step 10 http://predictor.service.tubi:8080/predictor/v1/get-ranking
总结
在这篇文章中,我们解释了为什么要对实时推荐服务做压力测试,介绍了 microbenchmark 和压力测试的基本概念,并演示了一遍压力测试的流程。我们还编写了一个开源工具 Autobench2 [8] 来简化压力测试流程。如果你对构建高吞吐量低延迟的后端服务感兴趣,快投简历到 Tubi 吧!
原文:Chiyu Zhong, Tubi Senior Backend Engineer
译者:Chun Shang, Tubi Engineering Director
点击 “阅读原文” 查看英文版