hj24.life

记一次扇贝账号服务的压测

2021.02.12

按照经验估计,每年年后过来四六级查分的时候都是扇贝各个业务的高峰期,也是扇贝新用户注册的高峰,19 年账号服务刚上线的时候因为这波高峰挂掉过,于是每年春节放假前对账号系统做压测就成了传统,这一次也是我接手账号服务之后做的第一次压测,做个简单的记录。

业务背景介绍和声明

  • 这个账号服务本文暂且给它一个代号叫 sbayuser,为了测试起的模拟微信的服务叫它 sbayuser-stub
  • 正常情况下接口的 qps 以 x 为单位
  • 在扇贝,我们的开发环境分为三套:it、预发布和线上,其中 it 环境主要用于功能开发结束后的简单集成测试,预发布环境和线上环境用了同一套数据,区别只在于非员工能否访问的到

本文旨在交流技术,隐去了一些扇贝的敏感信息,相关图片也做了打码处理。好了,废话不多说,直接进入正文。

接口确认

按以往的事故报告和压测记录来看,业务高峰期主要是「注册绑定登录」相关的接口 qps 会暴涨,导致数据库性能受影响,进一步导致业务挂掉,于是我调了几个主要注册接口的记录:

微信

微信

微博

微博

qq

qq

  • 这几个写接口,平时高峰期 qps 只有 x 左右,目前只有微信会略超过 x,但是类似备考四六级查分时的这种高峰,会涌入大量的新用户登录注册,qps 会涨到 10x+,而读接口涨到 100x+ 也是抗的住的,19 年的那次事故也是因为没扛住写的压力挂掉了,所以压测时的 qps 需要按 30x 为目标去压。
  • 因为 sbayuser 的 oauth 登录都抽象成了一个统一的流程,且这其中微信的 qps 最高,并且流程最长,所以压测时只取微信这个「POST」 接口,理论上只要它能压到 30x,其它几个都没有问题。

工具选择

市面上有很多压测工具,ab、locust、JMeter,很多云厂商也推出了云压测服务,不过这次还是要介绍一个更轻量级的压测工具 wrk,这个工具托管在 github,目前 star 数已经有 27.6k 了,感兴趣的可以去研究研究源码:wrk 项目地址

为什么选 wrk?

因为我不是专业的测试,暂时也没有太大的兴趣研究测开方向的一些工具,所以需要挑选一个相对轻量,上手简单的压测工具,所以 JMeter 或者 LoadRunner 这些专业工具首先就被排除在外了,而 wrk 对比这些工具有一些明显的优势:

  1. 轻量,安装简单:brew install wrk
  2. 学习成本很低,几分钟看一下文档就能开始上手了
  3. 纯 c 开发,基于操作系统自带的 IO 机制,比如 epoll,纯异步的事件驱动框架,通过比较少的线程就可以压出很大的并发量

不过凡事都有两面,wrk 目前仅支持单机压测,后续也不太可能支持多机器对目标机压测,因为它本身的定位,并不是用来取代专业的测试工具。

wrk 提供的功能,对我们后端开发人员来说,应付日常接口性能验证还是比较友好的。

压测方案

以下两种方案,都需要在各自的环境中将「登录注册绑定」相关请求中的微信 oauth 平台的部分替换为我们起的 stub 服务,这个服务模拟微信与账号服务交互,随机响应 union_id。

原因:真实的微信服务每次调用都需要请求临时凭证,这个凭证有 10 分钟时效,并且使用一次就会被消耗掉,每一轮压测时的每个请求都需要为其请求一个临时凭证,成本极高,考虑到性能瓶颈大都在写数据库,因此起一个假的微信服务是比较合理,且成本比较低的。

注:微信开放平台的文档可以看这里

方案一:线上压测

  1. 切出一个新的分支,去掉所有线上的打点,防止产生脏打点数据,将压测需要的微信服务部分替换成自己起的 sbayuser-stub,将该分支代码起一个新的 k8s deploy。
  2. 临时去掉线上 envoy 的 ratelimit 限制。
  3. envoy 起一个新的路由(比如前缀改成 /sbayuser/s)指向这个 deploy,需要压测的接口走这个前缀,避免线上用户请求 stub 服务,虽然线上会半夜压测,这种用户非常少,但还是不可忽略的,否则就需要停机压测。
  4. 恢复所有配置,envoy 路由和 ratelimit、deploy 等。
  5. 写脚本清理线上 mysql 和 redis 产生的脏数据。

优点

  • 可以使用和线上服务完全一样的配置和代码进行压测,压测结果更准确。

缺点

  • 压测过程中会在 mysql 和 redis 中产生大量的脏数据,尤其是 mysql,分库分表后清理脚本会比较难写,且删线上数据时会有风险。
  • 会浪费掉一大段 user_id,因为 sbayuser 全局 id 的生成利用了 redis 的 incr 操作,压注册接口时 id 会增长很多,很难简单的减回去,因为压测时会掺杂着真实用户的注册请求,简单的减回去可能会导致后续新用户注册拿到重复 id,有一定风险。
  • 线上压测,难免会影响真实用户,不过问题不会很大,因为是凌晨业务低谷压,且除了问题会立刻回滚。

方案二:it 环境压测「采纳」

  1. 准备两个 mysql 实例,一个 redis 实例(用于存储 session),和线上配置保持一致。
  2. 从阿里云迁移存量数据到华为云,跨云厂商迁移时需要给实例绑定好公网 ip,redis 则直接从备份的 rdb 文件迁移(华为云暂不支持给 redis 绑定公网 ip)。
  3. 参考线上,部署一套 kinshard 用来给 sbayuser 分库分表。
  4. 数据迁移完之后需要把 redis 的生成全局自 id 的 key 调大一点,避免和已经分配过得用户的 id 重复,这一点是压测时踩到的坑,需要注意一下,当然如果把用于存储 session 之外的另一个 redis 也迁移过来,就可以不用管。
  5. sbayuser 改代码,改动的地方包括:apm 写入地址改为公网地址(华为云访问不了阿里云)、mysql 地址改为 kingshard 地址且连接数和线上保持一致、改 redis 和 session_redis 地址、调用微信的接口改为 sbayuser-stub 的接口、it 的部署文件参数改为和线上一致。
  6. 修改 ci,push 到压测分支后直接 build 镜像,部署 it 环境,避免污染主分支代码,方便多次修改验证。
  7. 还原所有配置。

优点

  • it 压测,不用凌晨操作,可以压很多轮修改 bug + 验证自己的想法,不用担心影响用户。

缺点

  • 测试环境准备的过程比较复杂,不过好在大部分操作都有现成工具。
  • 华为云的 mysql、redis 实例虽说明面配置和阿里云一样,但仍有一点差异。

环境准备

Mysql & Redis

  • Mysql 配置:Mysql 5.6 4C8G X 2
  • Redis 配置:Redis 5.0 主备 8G

Mysql 两个分库从线上同步数据回来,两个分库数据量都在 46G 左右,存 session 的 Redis 用了 686MB 左右。

注:生产环境 Mysql 升级到了 4C16G,买的时候直接看了之前压测的文档,没有核对,但从压测效果来看还不错,所以理论上升级过后的线上数据库能抗住更大一点的压力。

sbayuser 项目

服务启动配置和线上基本保持一致,在 it 环境,gunicorn 以 gthread 模型启动,共 20 个 pod(线上是 25 个),pod 配置如下:

limits:
    cpu: 1300m
    memory: 600Mi
requests:
    cpu: 400m
    memory: 300Mi

注:gthread + 20 个 pod 这个配置在上一次压测可以扛住 30x+ 写请求的 qps,作为参考,备考上一次事故写请求 qps 到 10x+ 左右服务就挂了,并且目前线上一直是以这个模式运行服务,因此本次压测延用这些配置。

sbayuser-stub 项目

起这个服务的理由见上方的方案,stub 服务会起在 it 环境,使用 gevent 模式起一个可以扛住较大并发的 flask 项目,用于模拟 sbayuser 与微信交互,假微信会立即响应随机值,主要是随机响应 union_id。

经过测试,gunicorn 的参数调整成下面展示的之后可以扛住 80x+ 的 qps(k8s deploy 的 replicas 数是 5):

import os
# logging
accesslog = "-"
errorlog = "-"
loglevel = os.environ.get("LOGLEVEL", "warning")
# process naming
proc_name = "sbayuser-stub"
# server socket
bind = "0.0.0.0:5000"
# worker processes
workers = 5
# **shouldn't** call `patch_all()` by ourselves when use `gevent`
# https://github.com/benoitc/gunicorn/issues/1056#issuecomment-115409307
worker_class = "gevent"
threads = 4
keepalive = int(os.environ.get("KEEPALIVE_TIMEOUT", 30))

pod 配置如下:

limits:
    cpu: 1200m
    memory: 800Mi
requests:
    cpu: 400m
    memory: 300Mi

从 envoy 的监控可以看到,qps 可以到 80x+: sbayuser-stub qps

kingshard

因为 it 环境没有做分库分表的事,所以从线上同步回来数据之后需要起一个 kingshard,下面直接给一个 yaml 示例,你只需要替换文件中 {{}} (注意: {{}} != {})然后直接 apply 即可:

apiVersion: v1
kind: ConfigMap
metadata:
  name: sbayuser-it-config
  namespace: rds
data:
  ks.yaml: |
    addr: 0.0.0.0:3306
    user_list:
      - user: {{}}
        password: {{}}
    log_sql: on
    slow_log_time: 100
    proxy_charset: utf8mb4
    nodes:
    - name: sbayuser-node-1
      master: {{}}
      user: {{}}
      password: {{}}
      down_after_noalive: 300
      max_conns_limit: 128
    - name: sbayuser-node-2
      master: {{}}
      user: {{}}
      password: {{}}
      down_after_noalive: 300
      max_conns_limit: 128
    - name: sbayuser-default
      master: db-mysql.rds:3306
      user: {{}}
      password: {{}}
      down_after_noalive: 300
      max_conns_limit: 128
    schema_list:
    - user: {{}}
      nodes:
      - sbayuser-node-1
      - sbayuser-node-2
      - sbayuser-default
      default: sbayuser-default
      shard:
      - db: sbayuser
        table: susers
        key: id
        nodes:
        - sbayuser-node-1
        - sbayuser-node-2
        type: hash
        locations: [32, 32]
      - db: sbayuser
        table: suser_socials
        key: user_id
        nodes:
        - sbayuser-node-1
        - sbayuser-node-2
        type: hash
        locations: [32, 32]
      - db: sbayuser
        table: swechat_users
        key: user_id
        nodes:
        - sbayuser-node-1
        - sbayuser-node-2
        type: hash
        locations: [32, 32]
      - db: sbayuser
        table: sqq_users
        key: user_id
        nodes:
        - sbayuser-node-1
        - sbayuser-node-2
        type: hash
        locations: [32, 32]    
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sbayuser-it
  namespace: rds
spec:
  replicas: 6
  selector:
    matchLabels:
      app: kingshard
      instance: sbayuser-it
  template:
    metadata:
      labels:
        app: kingshard
        instance: sbayuser-it
    spec:
      containers:
      - name: kingshard
        resources:
          limits:
            cpu: "1100m"
            memory: "600Mi"
          requests:
            cpu: "400m"
            memory: "300Mi"
        image: {{}}
        imagePullPolicy: IfNotPresent
        command:
        - "./docker-entrypoint.sh"
        - "/usr/bin/kingshard"
        - "-config=/etc/kingshard/ks.yaml"
        ports:
        - containerPort: 3306
        volumeMounts:
        - mountPath: /opt/kingshard/
          name: config-volume
      volumes:
      - name: config-volume
        configMap:
          defaultMode: 420
          name: bayuser-it-config
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
---
apiVersion: v1
kind: Service
metadata:
  name: db-mysql-shard
  namespace: rds
spec:
  selector:
    app: kingshard
    instance: sbayuser-it
  type: ClusterIP
  ports:
    - name: mysql
      protocol: TCP
      port: 3306
      targetPort: 3306

压测结果

和之前两次用了一样的 wrk 压测参数做对比:

wrk 线程数 10,并发数 80,压测 10 分钟

wrk 结果:

envoy 每秒请求数:

envoy 每秒响应数:

apm 数据:

envoy 平均响应时间:

wrk 线程数 20,并发数 200,压测 10 分钟

wrk 结果:

envoy 每秒请求数:

envoy 每秒响应数:

apm:

envoy 响应时间:

在数据库规格比线上低的情况下,压测数据略好于上一次,有人可能会疑问看这些监控,响应都在几百毫秒,看起来挺慢的,其实 oauth 接口这个响应速度是正常的,因为接口里会去请求一些第三方的 api,而正常我们的业务 api(即只涉及 mysql、redis 和 rpc 调用)响应一般只有 10ms 左右。

踩坑 & 总结

  1. 环境准备好之后不要忘了把 redis 里用作生成全局 id 的 key 调成比线上略大一点,当然如果有需要,也可以把用于存储 session 之外的另一个 redis 也迁移过来,但是,因为 sbayuser 除了 session 和缓存之外并没有太多使用 redis 的地方。如果不管,压注册接口时就会产生大量 user_id 重复的 5xx 响应:

  2. 这次压测数据库配置比线上略低一点,部署的 pod 也略少一点,但是数据大多和上次压测差不多,少数指比上次略好,理论上完全保持一致的话效果会更好。

参考

  1. 华为云 & 阿里云测评对比
  2. 阿里云 DTS 数据迁移服务
  3. 华为云 redis 数据同步方案