按照经验估计,每年年后过来四六级查分的时候都是扇贝各个业务的高峰期,也是扇贝新用户注册的高峰,19 年账号服务刚上线的时候因为这波高峰挂掉过,于是每年春节放假前对账号系统做压测就成了传统,这一次也是我接手账号服务之后做的第一次压测,做个简单的记录。
业务背景介绍和声明
- 这个账号服务本文暂且给它一个代号叫 sbayuser,为了测试起的模拟微信的服务叫它 sbayuser-stub
- 正常情况下接口的 qps 以 x 为单位
- 在扇贝,我们的开发环境分为三套:it、预发布和线上,其中 it 环境主要用于功能开发结束后的简单集成测试,预发布环境和线上环境用了同一套数据,区别只在于非员工能否访问的到
本文旨在交流技术,隐去了一些扇贝的敏感信息,相关图片也做了打码处理。好了,废话不多说,直接进入正文。
接口确认
按以往的事故报告和压测记录来看,业务高峰期主要是「注册绑定登录」相关的接口 qps 会暴涨,导致数据库性能受影响,进一步导致业务挂掉,于是我调了几个主要注册接口的记录:
微信
微博
- 这几个写接口,平时高峰期 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 对比这些工具有一些明显的优势:
- 轻量,安装简单:
brew install wrk
- 学习成本很低,几分钟看一下文档就能开始上手了
- 纯 c 开发,基于操作系统自带的 IO 机制,比如 epoll,纯异步的事件驱动框架,通过比较少的线程就可以压出很大的并发量
不过凡事都有两面,wrk 目前仅支持单机压测,后续也不太可能支持多机器对目标机压测,因为它本身的定位,并不是用来取代专业的测试工具。
wrk 提供的功能,对我们后端开发人员来说,应付日常接口性能验证还是比较友好的。
压测方案
以下两种方案,都需要在各自的环境中将「登录注册绑定」相关请求中的微信 oauth 平台的部分替换为我们起的 stub 服务,这个服务模拟微信与账号服务交互,随机响应 union_id。
原因:真实的微信服务每次调用都需要请求临时凭证,这个凭证有 10 分钟时效,并且使用一次就会被消耗掉,每一轮压测时的每个请求都需要为其请求一个临时凭证,成本极高,考虑到性能瓶颈大都在写数据库,因此起一个假的微信服务是比较合理,且成本比较低的。
注:微信开放平台的文档可以看这里
方案一:线上压测
- 切出一个新的分支,去掉所有线上的打点,防止产生脏打点数据,将压测需要的微信服务部分替换成自己起的 sbayuser-stub,将该分支代码起一个新的 k8s deploy。
- 临时去掉线上 envoy 的 ratelimit 限制。
- envoy 起一个新的路由(比如前缀改成 /sbayuser/s)指向这个 deploy,需要压测的接口走这个前缀,避免线上用户请求 stub 服务,虽然线上会半夜压测,这种用户非常少,但还是不可忽略的,否则就需要停机压测。
- 恢复所有配置,envoy 路由和 ratelimit、deploy 等。
- 写脚本清理线上 mysql 和 redis 产生的脏数据。
优点
- 可以使用和线上服务完全一样的配置和代码进行压测,压测结果更准确。
缺点
- 压测过程中会在 mysql 和 redis 中产生大量的脏数据,尤其是 mysql,分库分表后清理脚本会比较难写,且删线上数据时会有风险。
- 会浪费掉一大段 user_id,因为 sbayuser 全局 id 的生成利用了 redis 的 incr 操作,压注册接口时 id 会增长很多,很难简单的减回去,因为压测时会掺杂着真实用户的注册请求,简单的减回去可能会导致后续新用户注册拿到重复 id,有一定风险。
- 线上压测,难免会影响真实用户,不过问题不会很大,因为是凌晨业务低谷压,且除了问题会立刻回滚。
方案二:it 环境压测「采纳」
- 准备两个 mysql 实例,一个 redis 实例(用于存储 session),和线上配置保持一致。
- 从阿里云迁移存量数据到华为云,跨云厂商迁移时需要给实例绑定好公网 ip,redis 则直接从备份的 rdb 文件迁移(华为云暂不支持给 redis 绑定公网 ip)。
- 参考线上,部署一套 kinshard 用来给 sbayuser 分库分表。
- 数据迁移完之后需要把 redis 的生成全局自 id 的 key 调大一点,避免和已经分配过得用户的 id 重复,这一点是压测时踩到的坑,需要注意一下,当然如果把用于存储 session 之外的另一个 redis 也迁移过来,就可以不用管。
- sbayuser 改代码,改动的地方包括:apm 写入地址改为公网地址(华为云访问不了阿里云)、mysql 地址改为 kingshard 地址且连接数和线上保持一致、改 redis 和 session_redis 地址、调用微信的接口改为 sbayuser-stub 的接口、it 的部署文件参数改为和线上一致。
- 修改 ci,push 到压测分支后直接 build 镜像,部署 it 环境,避免污染主分支代码,方便多次修改验证。
- 还原所有配置。
优点
- 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+:
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 左右。
踩坑 & 总结
-
环境准备好之后不要忘了把 redis 里用作生成全局 id 的 key 调成比线上略大一点,当然如果有需要,也可以把用于存储 session 之外的另一个 redis 也迁移过来,但是,因为 sbayuser 除了 session 和缓存之外并没有太多使用 redis 的地方。如果不管,压注册接口时就会产生大量 user_id 重复的 5xx 响应:
-
这次压测数据库配置比线上略低一点,部署的 pod 也略少一点,但是数据大多和上次压测差不多,少数指比上次略好,理论上完全保持一致的话效果会更好。