目录

项目准备

案例

项目架构 

运行项目

 make run启动项目

 make init向mysql插入数据

 访问一下商品搜索的接口

top

iostat 

 pidstat

 show processlist 命令

慢查询问题?索引导致?

创建索引 

 curl再次访问

查看app.py

重新思考

 DataService 应用对查询的影响

删除索引 

curl重新访问

停止 DataService 应用

再次curl

vmstat 命令观察服务端

查看dataservice的源代码

MySQL 的 MyISAM 引擎的特点

缓存建议

Redis性能案例

机器配置

项目架构

 docker启动项目

make run启动redis 和 app 

 向redis中插入数据

访问缓存接口

性能观察

观察Top 

iostat

pidstat

strace查看进程调用栈

lsof确定进程操作的文件

排查redis持久化参数

redis持久化参数

疑问

优化redis持久化参数

代码优化

源代码


项目准备

使用vagrant准备两台虚拟机机器

配置:2C2G 
服务端VM预先安装 docker、sysstat 等工具


 

案例

由三个容器组成,包括一个 MySQL 数据库应用一个商品搜索应用以及一个数据处理的应用。其中,商品搜索应用以 HTTP 的形式提供了一个接口。

  • /:返回 Index Page;
  • /db/insert/products/:插入指定数量的商品信息;
  • /products/:查询指定商品的信息,并返回处理时间

项目架构 

运行项目

这个项目有点复杂,需要一些前置知识,例如make、Makefile和Dockerfile的结合使用,这里应该也可以用Docker Compose或K8s完成.

git clone https://github.com/feiskyer/linux-perf-examples

makefile的内容,将三个docker 镜像管理起来。 

DATA_NUM ?= 10000

.PHONY: run
run: mysql dataservice
	docker run --name=app --network=container:mysql -itd feisky/mysql-slow

.PHONY: dataservice
dataservice:
	docker run --name=dataservice -itd --privileged feisky/mysql-dataservice


.PHONY: mysql
mysql:
	docker run --name=mysql -itd -p 10000:80 -m 800m feisky/mysql:5.6


.PHONY: init
init:
	docker exec -i mysql mysql -uroot -P3306 < tables.sql
	curl http://127.0.0.1:10000/db/insert/products/$(DATA_NUM)


.PHONY: build
build:
	docker build -t feisky/mysql-slow -f Dockerfile .
	docker build -t feisky/mysql:5.6 -f Dockerfile.mysql .
	docker build -t feisky/mysql-dataservice -f Dockerfile.dataservice .


.PHONY: test
test:
	curl http://127.0.0.1:10000/products/geektime


.PHONY: push
push:
	docker push feisky/mysql-slow
	docker push feisky/mysql:5.6
	docker push feisky/mysql-dataservice


.PHONY: clean
clean:
	docker rm -f app mysql dataservice || echo "Containers already deleted."

 make run启动项目

跑了6-7分钟三个应用就起来了。

 docker logs -f mysql

确认mysql 服务是否开启成功,如下代表启动成功,如有报错根据log排查.

商品搜索应用在 10000 端口监听是否正常

 make init向mysql插入数据

由于我的机器是SSD,因此修改makefile中插入的数量,改为5000笔。

5000笔跑了30分钟没出来,还是先用1W测

 访问一下商品搜索的接口

 返回数据为空,而且处理时间超过 2S,太慢了.. 这可是SSD呀.

因为是SSD,因此

top

SSD盘这点压力还能扛得住. 所以iowait很少。

iostat 

 pidstat

 show processlist 命令

 show full processlist 命令保证 SQL 语句不截断

mysql> show full processlist;
+----+------+-----------------+------+---------+------+--------------+-----------------------------------------------------+
| Id | User | Host            | db   | Command | Time | State        | Info                                                |
+----+------+-----------------+------+---------+------+--------------+-----------------------------------------------------+
| 27 | root | localhost       | test | Query   |    0 | init         | show full processlist                               |
| 28 | root | 127.0.0.1:42262 | test | Query   |    1 | Sending data | select * from products where productName='geektime' |
+----+------+-----------------+------+---------+------+--------------+-----------------------------------------------------+
2 rows in set (0.00 sec)
  • db 表示数据库的名字;

  • Command 表示 SQL 类型;

  • Time 表示执行时间;

  • State 表示状态;

  • 而 Info 则包含了完整的 SQL 语句。

慢查询问题?索引导致?

explain 确认索引

# 切换到 test 库
mysql> use test;
# 执行 explain 命令
mysql> explain select * from products where productName='geektime';
+----+-------------+----------+------+---------------+------+---------+------+-------+-------------+
| id | select_type | table    | type | possible_keys | key  | key_len | ref  | rows  | Extra       |
+----+-------------+----------+------+---------------+------+---------+------+-------+-------------+
|  1 | SIMPLE      | products | ALL  | NULL          | NULL | NULL    | NULL | 10000 | Using where |
+----+-------------+----------+------+---------------+------+---------+------+-------+-------------+
1 row in set (0.00 sec)
  • select_type 表示查询类型,而这里的 SIMPLE 表示此查询不包括 UNION 查询或者子查询;

  • table 表示数据表的名字,这里是 products;

  • type 表示查询类型,这里的 ALL 表示全表查询,但索引查询应该是 index 类型才对;

  • possible_keys 表示可能选用的索引,这里是 NULL;

  • key 表示确切会使用的索引,这里也是 NULL;

  • rows 表示查询扫描的行数,这里是 10000。

扫描行数高达 10000 行。你应该很容易想到优化方法,没有索引那我们就自己建立,给 productName 建立索引就可以了。

创建索引 

mysql> CREATE INDEX products_index ON products (productName);
ERROR 1170 (42000): BLOB/TEXT column 'productName' used in key specification without a key length

productName 是一个 BLOB/TEXT 类型,需要设置一个长度。所以,想要创建索引,就必须为 productName 指定一个前缀长度。

那前缀长度设置为多大比较合适呢?这里其实有专门的算法,即通过计算前缀长度的选择性,来确定索引的长度。不过,我们可以稍微简化一下,直接使用一个固定数值(比如 64),执行下面的命令创建索引:

mysql> CREATE INDEX products_index ON products (productName(64));
Query OK, 10000 rows affected (14.45 sec)
Records: 10000  Duplicates: 0  Warnings: 0

 curl再次访问

Got data: ()in 15.383180141448975 sec
Got data: ()in 15.384996891021729 sec
Got data: ()in 0.0021054744720458984 sec
Got data: ()in 0.003951072692871094 sec

查询时间已经从 15 秒缩短到了 3 毫秒。

查看app.py

#!/usr/bin/env python
# -*- coding: UTF-8 -*-
import pymysql
import random
import string
import time
from flask import Flask

config = {
    "host": "127.0.0.1",
    "user": "root",
    "port": 3306,
    "password": "",
    "database": "test"
}


app = Flask(__name__)


@app.route('/')
def index():
    return 'Index Page'


@app.route("/sleep/<num>")
def sleep(num):
    time.sleep(int(num))
    return "sleep " + num + "\n"


@app.route('/db/<table>/count')
def select(table):
    db = pymysql.connect(**config)
    cursor = db.cursor()
    sql = "SELECT count(*) FROM " + table
    cursor.execute(sql)
    data = cursor.fetchone()
    return str(data[0]) + "\n"


@app.route('/products/<product>')
def products(product):
    start = time.time()
    db = pymysql.connect(**config)
    cursor = db.cursor()
    sql = "select * from products where productName='%s'" % product
    cursor.execute(sql)
    data = cursor.fetchall()
    elapsed = (time.time() - start)
    return "Got data: " + str(data) + " in " + str(elapsed) + " sec \n"


@app.route('/db/insert/products/<num>')
def insert_products(num):
    values = []
    i = 0
    while i < int(num):
        i += 1
        productCode = ''.join(random.choices(
            string.ascii_letters + string.digits, k=10240))
        productName = ''.join(random.choices(
            string.ascii_letters + string.digits, k=10240))
        productLine = ''.join(random.choices(
            string.ascii_letters + string.digits, k=10240))
        productScale = ''.join(random.choices(
            string.ascii_letters + string.digits, k=10240))
        productVendor = ''.join(random.choices(
            string.ascii_letters + string.digits, k=10240))
        productDescription = ''.join(random.sample(
            string.ascii_letters + string.digits, 8))
        quantityInStock = random.randint(1, 1000)
        buyPrice = random.uniform(1.0, 10000.0)
        MSRP = random.uniform(10.0, 20000.0)

        value = (i, productCode, productName, productLine, productScale,
                 productVendor, productDescription, quantityInStock, buyPrice, MSRP)
        values.append(value)

    db = pymysql.connect(**config)
    cursor = db.cursor()
    sql = "INSERT INTO products (id, productCode, productName, productLine, productScale, productVendor, productDescription, quantityInStock, buyPrice, MSRP) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
    cursor.executemany(sql, values)
    db.commit()
    cursor.close()
    return 'insert ' + num + ' lines' + "\n"


if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True, port=80)
#host一定不要用默认的"127.0.0.1",不然容器启动,即使映射了端口,在浏览器中也仍然是无法访问服务的。
#将host设置为"0.0.0.0",这样Flask容器可以接受到宿主的请求。

重新思考

 DataService 应用对查询的影响

我们启动的几个容器应用。除了 MySQL 和商品搜索应用外,还有一个 DataService 应用。为什么这个案例开始时,要运行一个看起来毫不相关的应用呢?

实际上,DataService 是一个严重影响 MySQL 性能的干扰应用。抛开上述索引优化方法不说,这个案例还有一种优化方法,也就是停止 DataService 应用。

删除索引 

# 删除索引
$ docker exec -i -t mysql mysql
​
mysql> use test;
mysql> DROP INDEX products_index ON products;

curl重新访问

处理时间又变慢了:大概十几秒

while true; do curl http://192.168.0.10:10000/products/geektime; sleep 5; done
Got data: ()in 16.884345054626465 sec

停止 DataService 应用

docker rm -f dataservice

再次curl

Got data: ()in 16.884345054626465 sec
Got data: ()in 15.238174200057983 sec
Got data: ()in 0.12604427337646484 sec
Got data: ()in 0.1101069450378418 sec
Got data: ()in 0.11235237121582031 sec

停 DataService 后,处理时间从 15 秒缩短到了 0.1 秒,虽然比不上增加索引后的 3 毫秒,但相对于 15 秒来说,优化效果还是非常明显的

vmstat 命令观察服务端

vmstat 可以同时提供 CPU、内存和 I/O 的使用情况

vmstat 1
 
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 
# 备注: DataService 正在运行
0  1      0 7293416    132 366704    0    0 32516    12   36  546  1  3 49 48  0
 0  1      0 7260772    132 399256    0    0 32640     0   37  463  1  1 49 48  0
 0  1      0 7228088    132 432088    0    0 32640     0   30  477  0  1 49 49  0
 0  0      0 7306560    132 353084    0    0 20572     4   90  574  1  4 69 27  0
 0  2      0 7282300    132 368536    0    0 15468     0   32  304  0  0 79 20  0
 
# 备注:DataService 从这里开始停止
 0  0      0 7241852   1360 424164    0    0   864   320  133 1266  1  1 94  5  0
 0  1      0 7228956   1368 437400    0    0 13328     0   45  366  0  0 83 17  0
 0  1      0 7196320   1368 470148    0    0 32640     0   33  413  1  1 50 49  0
...
 0  0      0 6747540   1368 918576    0    0 29056     0   42  568  0  0 56 44  0
 0  0      0 6747540   1368 918576    0    0     0     0   40  141  1  0 100  0  0

在 DataService 停止前,cache 会连续增长三次后再降回去,这正是因为 DataService 每隔 3 秒清理一次页缓存。而 DataService 停止后,cache 就会不停地增长,直到增长为 918576 后,就不再变了。

这时,磁盘的读(bi)降低到 0,同时,iowait(wa)也降低到 0,这说明,此时的所有数据都已经在系统的缓存中了。我们知道,缓存是内存的一部分,它的访问速度比磁盘快得多,这也就能解释,为什么 MySQL 的查询速度变快了很多。

查看dataservice的源代码

#!/usr/bin/env python
# -*- coding: UTF-8 -*-
import time
filename = "/tmp/dataservice"


def write():
    with open(filename, "w+") as f:
        f.write("data")


def read():
    with open(filename) as f:
        return f.read()


def clean_cache():
    with open("/proc/sys/vm/drop_caches", "w") as f:
        f.write("1")

if __name__ == "__main__":
    while True:
        write()
        clean_cache()
        read()
        time.sleep(3)

ataService 实际上是在读写一个仅包括 “data” 字符串的小文件。不过在读取文件前,它会先把 /proc/sys/vm/drop_caches 改成 1。清空了缓存,导致mysql不能充分利用缓存所以变慢。

MySQL 的 MyISAM 和Innode引擎的对缓存的区别

MySQL 的 MyISAM 引擎,主要依赖系统缓存加速磁盘 I/O 的访问

MyISAM 的一个特点,就是只在内存中缓存索引,并不缓存数据。

InnoDB 引擎,就同时缓存了索引和数据

个人见解: 初期有很多mysql都是myisam引擎,主要是因为其值缓存index而不缓存数据,比较节省内存,到后来内存廉价之后 ,Innode引擎横空出世,既缓存index 也缓存data。

在这个案例中, 系统中还有其他应用同时运行(dataservice :定时清空页缓存), 所以停掉dataservice就恢复正常了。因为是共用系统,谁也说不准什么时候谁会去清空缓存。本身严重依赖OS的缓存就很不靠谱。

所以后来才有了Redis ..等缓存中间件系统。

缓存建议

一般我并不建议,把应用程序的性能优化完全建立在系统缓存上。最好能在应用程序的内部分配内存,构建完全自主控制的缓存;或者使用第三方的缓存应用,比如 Memcached、Redis 等。

Redis性能案例

机器配置

由于vagrant拉起来的VM是SSD,所以将其中一台使用ESC实例。

配置: 1c2g Centos

今天的案例由 Python 应用 +Redis 两部分组成。其中,Python 应用是一个基于 Flask 的应用,它会利用 Redis ,来管理应用程序的缓存,并对外提供三个 HTTP 接口:

  • /:返回 hello redis;

  • /init/:插入指定数量的缓存数据,如果不指定数量,默认的是 5000 条;

  • 缓存的键格式为 uuid:

  • 缓存的值为 good、bad 或 normal 三者之一

  • /get_cache/<type_name>:查询指定值的缓存数据,并返回处理时间。其中,type_name 参数只支持 good, bad 和 normal(也就是找出具有相同 value 的 key 列表)。

项目架构

 docker启动项目

makefile已经写好了,直接make run就好了 

make run启动redis 和 app 

 

 向redis中插入数据

调用接口

[root@ninesun ~]# curl http://8.142.104.69:10000/init/30000

阿里的云盘效能还可以,我实际测试时向redis写入30000笔数据

访问缓存接口

~]# curl http://8.142.104.69:10000/get_cache
{"count":1644,"data":["890752e8-79b8-11ec-a295-0242ac110002","8114a13a-79b8-11ec-a295-
...
0242ac110002","7fb1953c-79b8-11ec-a295-0242ac110002"],"elapsed_seconds":7.120203495025635,"type":"g

 为了方便观察,在另外一个VM中循环调用缓存接口:

 mysql-slow]# while true;do curl http://8.142.104.69:10000/get_cache 2>&1 >/dev/null; done

性能观察

观察Top 

 iowait已高达90%,cpu使用6%并不是很高。

iostat

ECS的这块云盘已经将近100%的使用率了。

w/s 写请求数达到800多,每秒写3M左右的数据(这是因为阿里云盘的限制,我电脑的SSD盘大约在300MB每秒)

pidstat

看到redis在疯狂的写数据,每秒7-8MB。结合刚才的iostat 3MB/s的吞吐量,可以断定这块盘有严重的性能问题了。

凶手就是redis。

strace查看进程调用栈

# -f 表示跟踪子进程和子线程,-T 表示显示系统调用的时长,-tt 表示显示跟踪时间
$ strace -f -T -tt -p 

 这是不加-T -tt的效果

从系统调用来看, epoll_pwait、read、write、fdatasync 这些系统调用都比较频繁。那么,刚才观察到的写磁盘,应该就是 write 或者 fdatasync 导致的了。 

lsof确定进程操作的文件

fd 3、4 的是一个 pipe 管道,5 是 eventpoll,7 号是一个普通文件,而 8 号是一个 TCP socket。

结合IO情况,只有 7 号普通文件才会产生磁盘写,而它操作的文件路径是 /data/appendonly.aof,相应的系统调用包括 write 和 fdatasync

排查redis持久化参数

既然定位到是redis在做持久化,那么是在做bgwriteaof 还是正常的持久化?

显然如果是重写日志文件不会持续频繁,因为大概率是设置了不合理的redis持久化参数(可能是always)。

通过strace -e 选项指定 fdatasync 

每间隔4-6ms调用一次fsync,其中<>中的是系统自身调用时间,大约在4-5ms,也就说阿里云ECS的盘效能贼好。

 至此我们就找出了 Redis 正在进行写入的文件,也知道了产生大量 I/O 的原因。

redis持久化参数

疑问

什么查询时会有磁盘写呢?按理来说不应该只有数据的读取吗?不是从缓存中取数据么,又不是往redis中写数据。

文件描述符编号为 7 的是一个普通文件 /data/appendonly.aof,而编号为 8 的是 TCP socket。而观察上面的内容,8 号对应的 TCP 读写,是一个标准的“请求 - 响应”格式,即:

  • 从 socket 读取 GET uuid:53522908-… 后,响应 good;

  • 再从 socket 读取 SADD good 535… 后,响应 1。

对 Redis 来说,SADD 是一个写操作,所以 Redis 还会把它保存到用于持久化的 appendonly.aof 文件中。持久化参数不同,刷新频率不同。

观察更多的 strace 结果,你会发现,每当 GET 返回 good 时,随后都会有一个 SADD 操作,这也就导致了,明明是查询接口,Redis 却有大量的磁盘写。

到这里,我们就找出了 Redis 写磁盘的原因。不过,在下最终结论前,我们还是要确认一下,8 号 TCP socket 对应的 Redis 客户端,到底是不是我们的案例应用。

我们可以给 lsof 命令加上 -i 选项,找出 TCP socket 对应的 TCP 连接信息。不过,由于 Redis 和 Python 应用都在容器中运行,我们需要进入容器的网络命名空间内部,才能看到完整的 TCP 连接。

由于这两个容器共享同一个网络命名空间,所以我们只需要进入 app 的网络命名空间即可(docker的知识需要补一下了)

[root@ninesun ~]# PID=$(docker inspect --format {{.State.Pid}} app)
[root@ninesun ~]# echo $PID
1599431
# -i 表示显示网络套接字信息
[root@ninesun ~]# nsenter --target $PID --net -- lsof -i

COMMAND       PID     USER   FD   TYPE  DEVICE SIZE/OFF NODE NAME
redis-ser 1599369      100    6u  IPv4 8252565      0t0  TCP localhost:redis (LISTEN)
redis-ser 1599369      100    8u  IPv4 8252869      0t0  TCP localhost:redis->localhost:56810 (ESTABLISHED)
python    1599431     root    3u  IPv4 8252816      0t0  TCP *:http (LISTEN)
python    1599431     root    4u  IPv4 8280913      0t0  TCP 172.17.0.2:http->111.18.146.45:danf-ak2 (ESTABLISHED)
python    1599431     root    5u  IPv4 8252868      0t0  TCP localhost:56810->localhost:redis (ESTABLISHED)

 localhost:6379 是 redis-server 自己的监听端口,自然 localhost:56810就是 redis 的客户端。

最后一行localhost:56810对应的,正是我们的 Python 应用程序 PID为1599431

此外还可以再redis通过monitor来观察 

优化redis持久化参数

 

在查看top、iostat 就没有io性能问题了

缓存接口查询0.4s左右结果就出来了。

代码优化

Python 应用把 Redis 当成临时空间,用来存储查询过程中找到的数据。不过我们知道,这些数据放内存中就可以了,完全没必要再通过网络调用存储到 Redis 中。修改后再调用接口

curl http://8.142.104.69:10000/get_cache_data

返回结果不到0.2s 

源代码

#!/usr/bin/env python
# -*- coding: UTF-8 -*-
from functools import wraps
from time import time
import json
import random
import uuid
import redis
from flask import Flask, jsonify

app = Flask(__name__)
redis_client = redis.StrictRedis(
    host="127.0.0.1", charset="utf-8", port=6379, db=0, decode_responses=True)


def timing(f):
    '''wrapper for elapsed_seconds'''
    @wraps(f)
    def wrapper(*args, **kwargs):
        start = time()
        result = f(*args, **kwargs)
        end = time()
        data = json.loads(result.get_data())
        data["elapsed_seconds"] = end-start
        return jsonify(data)
    return wrapper


@app.route('/')
def hello_world():
    '''handler for /'''
    return 'hello redis\n'


@app.route('/get_cache', defaults={'type_name': 'good'})
@app.route("/get_cache/<type_name>")
@timing
def get_cache(type_name):
    '''handler for /get_cache'''
    for key in redis_client.scan_iter("uuid:*"):
        value = redis_client.get(key)
        if value == type_name:
            redis_client.sadd(type_name, key[5:])
    data = list(redis_client.smembers(type_name))
    redis_client.delete(type_name)
    return jsonify({"type": type_name, 'count': len(data), 'data': data})


@app.route('/get_cache_data', defaults={'type_name': 'good'})
@app.route("/get_cache_data/<type_name>")
@timing
def get_cache_data(type_name):
    '''handler for /get_cache_data'''
    keys = [key for key in redis_client.scan_iter("uuid:*")]
    values = redis_client.mget(keys)
    matched_keys = set([keys[idx][5:] for idx, value in enumerate(values) if value == type_name ])
    return jsonify({"type": type_name, 'count': len(matched_keys), 'data': list(matched_keys)})


@app.route('/init', defaults={'num':5000})
@app.route('/init/<num>')
@timing
def initialize(num):
    '''handler for /init'''
    redis_client.flushall()
    types = ('good', 'bad', 'normal')
    sample_size = int(num)
    random_pool = random.sample(range(0, sample_size), sample_size)

    for i in range(sample_size):
        key = uuid.uuid1()
        redis_client.set('uuid:{}'.format(key), types[(random_pool[i]+i) % 3])

    return jsonify({"keys_initialized": sample_size})


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐