性能测试在 Tubi 的实践

avatar
HR @Tubi

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 等),理想情况下,我们希望编写一个用最少机器满足吞吐量和延迟需求的服务。

流程

在了解了上面几个指标后,通常压力测试会按照下面步骤执行:

  1. 定义期望的服务延迟、吞吐量和错误率指标

  2. 发送流量到目标服务来预热(warm-up)

  3. 发送测试流量到目标服务,记录延迟、吞吐量、错误率和机器资源指标

  4. 逐步增加测试流量

  5. 通过记录的指标,判断目标服务是否满足 #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

点击 “阅读原文” 查看英文版

图片