Sysbench 是数据库压测中很常用的工具。它本身不是只面向数据库的压测框架,也支持 CPU、内存、文件 IO、线程、互斥锁等测试,但在数据库场景里,最常用的还是基于 Lua 脚本实现的 OLTP workload,例如点查、范围查、只读、读写、写入、更新、删除等。
这篇文章记录一下 sysbench 的编译、常用命令、OLTP 参数,以及从源码角度看一次压测任务是如何跑起来的。
准备
编译
本地编译 debug 版本:
git clone https://github.com/akopytov/sysbench.git
cd sysbench
./autogen.sh
./configure --with-debug --prefix=/opt/sb
make -j$(nproc)
make install
如果只是日常使用,也可以直接通过包管理器安装。但调源码时最好自己编译一份 debug 版本,方便下断点和观察参数传递。
/opt/sb/bin/sysbench --version
/opt/sb/bin/sysbench --help
数据库压测需要依赖对应 client 库。以 MySQL 为例,如果 configure 阶段找不到 MySQL client,可以把上面的 configure 换成下面这种显式写法:
./configure --with-debug --prefix=/opt/sb --with-mysql \
--with-mysql-includes=/usr/include/mysql \
--with-mysql-libs=/usr/lib/x86_64-linux-gnu
vscode 开发环境
sysbench 是 autotools 项目,不像 CMake 项目那样天然生成 compile_commands.json。如果想在 VSCode 里获得比较好的跳转、补全和诊断体验,可以用 bear 包一层编译命令,生成 clangd 可读的编译数据库。
先安装常用工具:
sudo apt install -y bear clangd gdb
然后在已经 configure 过的源码目录里重新编译,并生成 compile_commands.json:
make clean
bear -- make -j$(nproc)
成功后,sysbench 源码根目录下会出现:
compile_commands.json
VSCode 建议使用 clangd 扩展,而不是默认的 C/C++ IntelliSense。可以在工作区配置里指定 clangd 读取当前目录的编译数据库:
{
"clangd.arguments": [
"--compile-commands-dir=${workspaceFolder}",
"--background-index",
"--clang-tidy"
],
"C_Cpp.intelliSenseEngine": "disabled"
}
如果 clangd 仍然找不到 MySQL 或 LuaJIT 头文件,优先检查 compile_commands.json 里的编译命令是否包含正确的 -I 路径,而不是手工在 VSCode 里补 include path。对于 autotools 项目,编译数据库应该来自真实编译命令,否则跳转结果很容易和实际编译不一致。
调试可以直接使用本地构建出来的二进制,路径通常是:
src/sysbench
一个最小的 .vscode/launch.json 配置如下:
{
"version": "0.2.0",
"configurations": [
{
"name": "sysbench point select",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/src/sysbench",
"args": [
"oltp_point_select",
"--db-driver=mysql",
"--mysql-host=127.0.0.1",
"--mysql-port=3306",
"--mysql-user=sbtest",
"--mysql-password=sbtest",
"--mysql-db=sbtest",
"--tables=1",
"--table-size=1000",
"--threads=1",
"--events=1",
"run"
],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"MIMode": "gdb",
"setupCommands": [
{
"description": "Enable pretty printing",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
]
}
]
}
调源码时建议先像上面的 launch.json 一样,把并发和事件数降到最低。这样更容易单步跟踪,不会被多线程输出和定时统计打断。等主流程看清楚之后,再把 --events 和 --threads 调大。
常用断点位置:
main:观察命令行参数如何解析- test 注册和查找逻辑:观察
oltp_point_select如何映射到 Lua 脚本 - Lua 脚本入口:观察
prepare、event、cleanup如何被调度 - 数据库 driver:观察
sysbench.sqlAPI 如何落到 MySQL client library - worker 线程入口:观察
--threads如何创建并发执行单元
如果只想看 Lua workload,可以先在脚本里临时加 print(),再用单线程、单事件跑一轮。等确认脚本逻辑后,再进入 C 层调试。
使用指南
sysbench 命令的基本形态是:
sysbench [通用参数] [测试名或脚本路径] [命令]
数据库测试常见命令有三个:
prepare:建表、导入初始数据run:执行压测cleanup:清理测试表
通用参数控制压测框架本身,例如线程数、总时长、事件数、报告间隔;数据库参数控制连接信息和测试表规模。
建表、导数据
以 MySQL 为例,先准备一个测试库:
CREATE DATABASE sbtest;
CREATE USER 'sbtest'@'%' IDENTIFIED BY 'sbtest';
GRANT ALL PRIVILEGES ON sbtest.* TO 'sbtest'@'%';
然后用 sysbench 创建测试表并导入数据:
sysbench oltp_read_write \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-port=3306 \
--mysql-user=sbtest \
--mysql-password=sbtest \
--mysql-db=sbtest \
--tables=16 \
--table-size=100000 \
prepare
这条命令会创建 sbtest1 到 sbtest16 共 16 张表,每张表 10 万行。表结构大致如下:
CREATE TABLE sbtest1 (
id INTEGER NOT NULL AUTO_INCREMENT,
k INTEGER DEFAULT '0' NOT NULL,
c CHAR(120) DEFAULT '' NOT NULL,
pad CHAR(60) DEFAULT '' NOT NULL,
PRIMARY KEY (id),
KEY k_1 (k)
);
几个常用参数:
| 参数 | 说明 |
|---|---|
--tables | 测试表数量 |
--table-size | 每张表的数据行数 |
--mysql-db | 测试库名 |
--mysql-host | 数据库地址 |
--mysql-port | 数据库端口 |
--mysql-user | 用户名 |
--mysql-password | 密码 |
--db-driver | 数据库驱动,MySQL 场景使用 mysql |
压测前最好先确认数据已经导入完成:
SELECT COUNT(*) FROM sbtest1;
SHOW CREATE TABLE sbtest1\G
跑测试
一个典型的读写混合压测命令如下:
sysbench oltp_read_write \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-port=3306 \
--mysql-user=sbtest \
--mysql-password=sbtest \
--mysql-db=sbtest \
--tables=16 \
--table-size=100000 \
--threads=32 \
--time=300 \
--report-interval=10 \
run
这里的 oltp_read_write 是 sysbench 自带的 Lua 脚本名。run 阶段会启动 32 个 worker 线程,持续执行 300 秒,并且每 10 秒打印一次阶段性统计。
压测结束后,sysbench 会输出类似这些指标:
transactions:事务总数和 TPSqueries:SQL 总数和 QPSlatency:延迟分布,包括最小、平均、最大和 95 分位threads fairness:不同线程执行事件数和执行时间的均衡程度
如果只想跑固定事务数,可以使用 --events:
sysbench oltp_point_select \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-user=sbtest \
--mysql-password=sbtest \
--mysql-db=sbtest \
--tables=16 \
--table-size=100000 \
--threads=16 \
--events=100000 \
run
--time 和 --events 同时存在时,哪个条件先满足就停止。做吞吐对比时,通常使用固定时长;做 profiling 或调试某个路径时,固定事件数更方便。
清理数据
测试完成后,可以删除测试表:
sysbench oltp_read_write \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-user=sbtest \
--mysql-password=sbtest \
--mysql-db=sbtest \
--tables=16 \
cleanup
cleanup 只会删除 sysbench 创建的测试表,不会删除数据库本身。
常用 OLTP workload
sysbench 自带的 OLTP workload 都是 Lua 脚本。常用脚本如下:
| 脚本 | 说明 |
|---|---|
oltp_read_write | 读写混合,默认包含点查、范围查、更新、删除、插入 |
oltp_read_only | 只读事务,包含点查和范围查 |
oltp_write_only | 只写事务,包含更新、删除、插入 |
oltp_point_select | 主键点查 |
oltp_read_write.lua | 直接指定 Lua 脚本路径也可以 |
select_random_points | 随机点查 |
select_random_ranges | 随机范围查 |
这些脚本大多复用了 oltp_common.lua 中的公共逻辑。可以把 oltp_common.lua 理解为 OLTP workload 的主体,而具体脚本只是选择打开哪些 SQL 模板。
读写混合事务通常包含如下 SQL:
BEGIN;
SELECT c FROM sbtestN WHERE id = ?;
SELECT c FROM sbtestN WHERE id BETWEEN ? AND ?;
SELECT SUM(k) FROM sbtestN WHERE id BETWEEN ? AND ?;
SELECT c FROM sbtestN WHERE id BETWEEN ? AND ? ORDER BY c;
SELECT DISTINCT c FROM sbtestN WHERE id BETWEEN ? AND ? ORDER BY c;
UPDATE sbtestN SET k = k + 1 WHERE id = ?;
UPDATE sbtestN SET c = ? WHERE id = ?;
DELETE FROM sbtestN WHERE id = ?;
INSERT INTO sbtestN (id, k, c, pad) VALUES (?, ?, ?, ?);
COMMIT;
实际 SQL 数量会受脚本参数影响,例如:
| 参数 | 说明 |
|---|---|
--point-selects | 每个事务中的点查次数 |
--range-selects | 是否开启范围查询 |
--simple-ranges | 简单范围查询次数 |
--sum-ranges | 聚合范围查询次数 |
--order-ranges | 排序范围查询次数 |
--distinct-ranges | 去重范围查询次数 |
--index-updates | 索引列更新次数 |
--non-index-updates | 非索引列更新次数 |
--delete-inserts | 删除后插入次数 |
所以,看到 oltp_read_write 的 TPS 时,要注意它不是“一条 SQL 的吞吐”,而是“一个事务脚本的吞吐”。如果要观察单条 SQL 的能力,应该使用更窄的 workload,例如 oltp_point_select。
自定义 Lua 脚本
sysbench 最方便的地方是可以自己写 Lua workload。只要脚本暴露出 prepare、event、cleanup 这些函数,就可以接入 sysbench 的执行框架。
脚本放在哪里
自定义脚本可以放在任意目录,运行时把脚本路径传给 sysbench 即可。最简单的做法是放在当前工作目录:
workloads/
my_workload.lua
然后这样运行:
sysbench ./workloads/my_workload.lua prepare
sysbench ./workloads/my_workload.lua --threads=4 --time=10 run
sysbench ./workloads/my_workload.lua cleanup
如果是在 sysbench 源码里调试,也可以放在源码目录下单独建一个 workloads/ 或 scripts/ 目录。不要直接改自带的 src/lua/ 脚本,除非目标就是研究 sysbench 内置脚本;自定义 workload 单独放更方便做版本管理,也能避免升级 sysbench 时被覆盖。
如果脚本里要 require 其他 Lua 文件,建议把公共文件放在同一目录,然后在启动时从脚本所在目录执行,或者显式设置 Lua 的加载路径。最省心的方式是先把单个 workload 写成一个文件,等逻辑稳定后再拆公共模块。
最小模板如下:
function prepare()
print("prepare")
end
function event()
print("event")
end
function cleanup()
print("cleanup")
end
运行方式:
sysbench ./workloads/my_workload.lua prepare
sysbench ./workloads/my_workload.lua --threads=4 --time=10 run
sysbench ./workloads/my_workload.lua cleanup
不过数据库压测一般需要自己声明参数、创建连接、执行 SQL。下面是一个可以直接改的模板。
#!/usr/bin/env sysbench
sysbench.cmdline.options = {
table_name = {"target table name", "sb_custom"},
table_size = {"number of rows", 100000},
}
local drv
local con
local stmt_point_select
local stmt_update
function prepare()
drv = sysbench.sql.driver()
con = drv:connect()
con:query(string.format([[
CREATE TABLE IF NOT EXISTS %s (
id BIGINT NOT NULL PRIMARY KEY,
k BIGINT NOT NULL DEFAULT 0,
c VARCHAR(128) NOT NULL DEFAULT '',
KEY idx_k (k)
)
]], sysbench.opt.table_name))
con:query("BEGIN")
for i = 1, sysbench.opt.table_size do
con:query(string.format(
"INSERT INTO %s (id, k, c) VALUES (%d, %d, 'payload-%d')",
sysbench.opt.table_name, i, i, i
))
end
con:query("COMMIT")
end
function thread_init()
drv = sysbench.sql.driver()
con = drv:connect()
stmt_point_select = con:prepare(string.format(
"SELECT c FROM %s WHERE id = ?",
sysbench.opt.table_name
))
stmt_update = con:prepare(string.format(
"UPDATE %s SET k = k + 1 WHERE id = ?",
sysbench.opt.table_name
))
end
function event()
local id = sysbench.rand.default(1, sysbench.opt.table_size)
con:query("BEGIN")
stmt_point_select:bind_param(id)
stmt_point_select:execute()
stmt_update:bind_param(id)
stmt_update:execute()
con:query("COMMIT")
end
function thread_done()
stmt_point_select:close()
stmt_update:close()
con:disconnect()
end
function cleanup()
drv = sysbench.sql.driver()
con = drv:connect()
con:query("DROP TABLE IF EXISTS " .. sysbench.opt.table_name)
end
使用这个脚本压 MySQL:
sysbench ./my_workload.lua \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-port=3306 \
--mysql-user=sbtest \
--mysql-password=sbtest \
--mysql-db=sbtest \
--table-name=sb_custom \
--table-size=100000 \
prepare
sysbench ./my_workload.lua \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-port=3306 \
--mysql-user=sbtest \
--mysql-password=sbtest \
--mysql-db=sbtest \
--table-name=sb_custom \
--table-size=100000 \
--threads=32 \
--time=300 \
--report-interval=10 \
run
脚本生命周期
自定义脚本里最常用的入口函数如下:
| 函数 | 调用时机 |
|---|---|
prepare() | 执行 prepare 命令时调用,通常用来建表和导入数据 |
thread_init() | run 阶段每个 worker 线程启动时调用一次 |
event() | run 阶段每个 worker 反复调用,是压测主体 |
thread_done() | 每个 worker 线程退出前调用一次 |
cleanup() | 执行 cleanup 命令时调用,通常用来删表 |
通常不建议在 event() 里创建连接或 prepare statement,因为 event() 会被高频调用。连接、prepared statement、临时对象应该尽量放到 thread_init(),释放逻辑放到 thread_done()。
自定义参数
sysbench.cmdline.options 可以声明脚本自己的命令行参数:
sysbench.cmdline.options = {
tenant_id = {"tenant id", 1},
range_size = {"range query size", 100},
enable_update = {"enable update", true},
}
使用时通过 sysbench.opt 读取:
local tenant_id = sysbench.opt.tenant_id
local range_size = sysbench.opt.range_size
命令行里使用连字符形式传参:
sysbench ./my_workload.lua \
--tenant-id=42 \
--range-size=1000 \
--enable-update=off \
run
Lua 代码里使用下划线,命令行里使用连字符,这是 sysbench 脚本里容易写错的地方。
随机数和热点
自定义 workload 时,随机数分布比 SQL 模板更容易影响结果。常见写法:
local id = sysbench.rand.default(1, sysbench.opt.table_size)
local table_id = sysbench.rand.uniform(1, sysbench.opt.tables)
sysbench.rand.default 会使用命令行指定的默认随机分布,受 --rand-type 影响。uniform 是均匀分布。压测热点场景时,可以显式指定:
sysbench ./my_workload.lua \
--rand-type=pareto \
--rand-pareto-h=0.2 \
run
均匀分布更适合测整体吞吐;偏斜分布更适合模拟热点行、热点页和缓存命中率变化。
写脚本时的注意事项
自定义脚本容易踩的坑:
prepare和run使用的参数不一致,导致随机 id 范围超过真实数据范围- 在
event()中拼接复杂字符串,客户端 CPU 成为瓶颈 - 每次 event 都重新连接数据库,测到的是连接开销
- 没有显式事务,导致每条 SQL 都自动提交
- 忘记关闭 prepared statement 和连接,长时间压测后客户端资源异常
如果目标是压数据库执行路径,应该尽量把客户端侧逻辑写简单,把随机数、字符串拼接和对象创建移出热路径。
参数选择
表数量
--tables 不只是数据规模参数,也会影响热点分布。表数量太少时,所有线程集中访问少量表,容易放大锁竞争;表数量太多时,又可能引入额外的 table cache、metadata lock、buffer pool 命中率变化等因素。
如果目标是压数据库整体吞吐,表数量通常设置为接近或大于并发线程数。如果目标是观察单表热点竞争,就应该刻意减少表数量。
数据规模
--table-size 决定每张表的数据量。压测前最好明确数据集是否大于 buffer pool:
- 数据完全在内存中:更偏向 CPU、锁、SQL 执行器开销
- 数据大于内存:更容易观察 IO、刷脏、预读、checkpoint 等行为
同一个 workload,在这两种场景下得到的结论可能完全不同。因此压测报告里至少要写清楚 tables * table_size、单行大小、buffer pool 大小和磁盘类型。
并发度
--threads 控制 worker 线程数。常见做法是从低并发开始逐步增加,例如:
for t in 1 2 4 8 16 32 64 128; do
sysbench oltp_read_write \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-user=sbtest \
--mysql-password=sbtest \
--mysql-db=sbtest \
--tables=16 \
--table-size=100000 \
--threads=$t \
--time=120 \
--report-interval=10 \
run
done
吞吐通常会经历三个阶段:
- 并发增加,TPS 近似线性上涨
- 到达瓶颈点,TPS 增长变慢
- 继续增加并发,TPS 持平甚至下降,延迟快速上升
第三阶段通常比单个 TPS 数字更有价值,因为它暴露了系统真正的瓶颈。
限速
--rate 可以限制事件产生速率,适合做稳定性测试或延迟曲线测试:
sysbench oltp_read_write \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-user=sbtest \
--mysql-password=sbtest \
--mysql-db=sbtest \
--tables=16 \
--table-size=100000 \
--threads=64 \
--rate=2000 \
--time=600 \
run
不限速时,sysbench 会尽可能打满数据库;限速时,它更像一个可控的流量发生器。做 latency SLO 观察时,限速比无脑打满更有意义。
源码阅读背景
前面几节偏使用方法。从这里开始,重点切到源码阅读。读 sysbench 源码前,最好先明确两个前置概念:LuaJIT 在这里扮演什么角色,以及 sysbench 统计里的 event 到底代表什么。
LuaJIT
sysbench 的数据库 workload 主要由 Lua 脚本描述。C 代码负责压测框架、线程调度、计时统计、数据库驱动封装;Lua 负责定义一轮测试事件中到底执行什么 SQL。
这种设计带来两个好处:
- workload 容易改,不需要重新编译 sysbench
- C 框架可以复用,数据库测试、文件 IO 测试、CPU 测试都走同一套事件调度和统计逻辑
Lua 脚本通常会实现几个函数:
function prepare()
-- 建表、导入数据
end
function event()
-- 每个 worker 反复执行的测试事件
end
function cleanup()
-- 清理测试对象
end
prepare、cleanup 是阶段性入口;event 是真正的压测主体。run 阶段里,每个 worker 线程会不断调用 event,直到满足 --time 或 --events 指定的停止条件。
事件模型
sysbench 的核心抽象是 event。不同测试类型的 event 含义不同:
oltp_point_select:一次 event 通常是一条点查oltp_read_write:一次 event 通常是一个事务fileio:一次 event 可能是一次文件读写cpu:一次 event 是一次 CPU 计算任务
所以对比不同 workload 的 events/s 时要非常小心。events/s 只有在 event 语义一致时才有直接可比性。
执行流程
从一次 sysbench oltp_read_write ... run 看,整体流程可以拆成几个阶段。如果从源码角度看,关键是先区分两层:
- C 层的 benchmark framework:负责参数、线程、计时、统计、限速、事件调度
- Lua 层的 workload:负责生成 SQL、选择表、选择随机 id、调用数据库 API
大致调用关系可以理解为:
main
├─ 初始化全局参数和内置 test
├─ 解析命令行,得到 test name 和 command
├─ 查找 test,必要时加载 Lua 脚本
├─ 执行 test 的 init/prepare/run/cleanup
└─ 输出统计结果
1. 解析命令行
sysbench 启动后先解析通用参数、测试名和命令。命令行最后的 prepare、run、cleanup 决定进入哪个阶段;中间的 oltp_read_write 决定加载哪个测试脚本。
典型命令:
sysbench oltp_read_write --threads=32 --time=300 run
可以理解为:
- 测试类型:
oltp_read_write - 并发线程:32
- 运行时长:300 秒
- 执行动作:
run
源码里需要注意两个概念:
- command:
prepare、run、cleanup、help这类动作 - test:
cpu、memory、fileio、oltp_read_write这类测试对象
命令行解析完成后,sysbench 会先确定 command,再根据 test name 找到具体的 test 实现。这个设计使得同一个 test 可以响应不同 command,例如 OLTP 脚本可以同时实现 prepare、run 和 cleanup。
2. 加载测试模块
如果测试名是内置 C 模块,例如 cpu、memory、fileio,sysbench 会加载对应的 C test。如果测试名是 OLTP 脚本名或 Lua 文件路径,则会进入 Lua test 路径。
oltp_read_write 最终会对应到 sysbench 自带的 Lua 脚本。脚本再引用公共逻辑,生成 SQL 模板和执行函数。
可以把 test 抽象成一组函数指针:
struct sb_test {
const char *name;
int (*init)(void);
int (*prepare)(void);
int (*run)(void);
int (*cleanup)(void);
int (*done)(void);
};
具体字段名不必死记,重点是这个结构表达的思想:C 层不关心 workload 的细节,它只在合适的阶段调用 test 暴露出来的生命周期函数。
Lua test 会做两件事:
- 创建 Lua state,加载 sysbench 暴露给 Lua 的 API
- 加载用户指定的 Lua 脚本,并检查脚本里是否定义了
prepare、event、cleanup等函数
所以 sysbench oltp_read_write run 并不是 C 代码里写死了一套 read-write 测试逻辑,而是 C 框架调用 Lua 脚本中的 event()。
3. 初始化数据库连接
在 prepare 阶段,sysbench 会创建连接并执行建表、插入数据逻辑。在 run 阶段,每个 worker 线程会持有自己的数据库连接,并在循环里执行脚本定义的 SQL。
这意味着 --threads=32 通常会对应 32 个并发连接。观察数据库端状态时,可以用:
SHOW PROCESSLIST;
SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW GLOBAL STATUS LIKE 'Questions';
SHOW GLOBAL STATUS LIKE 'Com_commit';
数据库能力也是通过 C 层注册给 Lua 的。Lua 脚本中常见的调用:
con = sysbench.sql.driver():connect()
con:query("BEGIN")
stmt = con:prepare("SELECT c FROM sbtest1 WHERE id = ?")
rs = stmt:execute()
con:query("COMMIT")
背后会进入 sysbench 的数据库抽象层。这个抽象层再根据 --db-driver=mysql 选择 MySQL driver,最终调用 MySQL client library。简化链路如下:
Lua event()
└─ sysbench.sql API
└─ db driver 抽象层
└─ mysql driver
└─ mysql_real_query / prepared statement API
└─ mysqld
因此,调数据库请求路径时可以从两边下断点:
- Lua/C 边界:确认脚本调用了哪条 SQL
- MySQL client 边界:确认最终发给数据库的 SQL 或 prepared statement 参数
4. 启动 worker 线程
run 阶段开始后,sysbench 创建 worker 线程。每个 worker 做的事情可以概括为:
while not stopped:
生成本轮 event 的随机参数
调用 Lua event()
执行 SQL
记录本轮 event 的耗时和错误
主线程负责控制生命周期、定期汇总统计、响应停止条件。--report-interval 打印的是阶段性结果,不是最终结果的简单切片,所以分析时要看趋势,而不是只看最后一行。
源码层面,worker loop 里通常会围绕三件事展开:
- 节流:如果设置了
--rate,先根据事件产生速率决定是否等待 - 执行:调用 test 的 event 入口,Lua workload 场景就是调用 Lua
event() - 统计:记录 event 耗时、错误数、重连数,并把结果汇总到线程本地统计里
用伪代码描述就是:
while (!sb_globals.done) {
if (rate_limited)
wait_for_next_event();
event_start = now();
rc = test->ops.event(thread_id);
event_end = now();
update_thread_stats(event_end - event_start, rc);
if (time_or_events_reached)
request_shutdown();
}
这里有两个容易误解的点:
- event 的粒度由 workload 决定,C 框架只负责“调用一次 event 并统计一次”
- 统计通常先落在线程本地结构里,再由主线程周期性汇总,避免所有 worker 高频抢同一个全局锁
5. OLTP 脚本执行
oltp_read_write 这类脚本的主体在 oltp_common.lua。它会根据命令行参数决定打开哪些 SQL 模板,然后在每次 event() 中执行一组 SQL。
典型结构可以简化为:
function event()
local table_name = "sbtest" .. sysbench.rand.uniform(1, sysbench.opt.tables)
local id = sysbench.rand.default(1, sysbench.opt.table_size)
con:query("BEGIN")
execute_point_selects(table_name, id)
execute_range_selects(table_name, id)
execute_index_updates(table_name, id)
execute_non_index_updates(table_name, id)
execute_delete_inserts(table_name, id)
con:query("COMMIT")
end
实际代码会更复杂一些,因为它需要处理:
- 是否使用 prepared statement
- 是否跳过事务
- 是否开启不同类型的 range query
- 每个事务中不同 SQL 的执行次数
- 随机数分布,例如 uniform、gaussian、pareto
这里最值得关注的是随机数。sysbench 并不是简单地每次都均匀随机访问一行,它支持多种随机分布。不同分布会直接改变热点程度,进而影响 buffer pool 命中率、锁冲突和索引页竞争。因此看源码时不能只看 SQL 模板,也要看 id 是怎么生成的。
6. prepare 的源码流程
prepare 阶段不是 worker 压测循环,而是执行建表和导数。OLTP 脚本通常会做:
for table_id in 1..tables:
CREATE TABLE sbtestN (...)
INSERT 初始数据
CREATE INDEX 或者建表时创建索引
导数通常会批量插入。流程上可以理解为:
Lua prepare()
└─ create_table()
├─ DROP TABLE IF EXISTS sbtestN
├─ CREATE TABLE sbtestN (...)
└─ bulk_insert_init / bulk_insert_next / bulk_insert_done
--table-size 在这里决定每张表插入多少行;--tables 决定循环创建多少张表。run 阶段依赖这两个参数生成随机表名和随机 id,所以 prepare 和 run 参数不一致会直接导致访问范围错误。
7. cleanup 的源码流程
cleanup 阶段相对简单,基本就是按表名循环执行:
DROP TABLE IF EXISTS sbtestN;
它和 prepare 一样走 Lua 脚本入口,但不进入 worker event loop。这个区别很重要:prepare / cleanup 更像管理动作,run 才是 benchmark 动作。
8. 结果汇总
压测过程中,worker 持续产生线程本地统计,主线程按 --report-interval 定期取快照并打印增量结果。压测结束后,再输出全局汇总。
统计链路可以概括为:
worker thread
└─ thread-local stats
└─ interval report
└─ final report
延迟统计一般不是只保存一个平均值,而是会维护可计算分位数的数据结构。最终输出的 95th percentile 就来自这部分统计。平均延迟容易被长尾掩盖,所以看压测结果时,p95 通常比 avg 更接近线上体验。
常见分析顺序是:
- 看 error 和 reconnect,确认压测本身是否有效
- 看 TPS/QPS,确认吞吐是否符合预期
- 看 avg latency 和 p95 latency,判断响应时间
- 看 report interval 的趋势,确认是否存在明显抖动
- 结合数据库内部指标定位瓶颈
常见坑
只看 TPS
TPS 是事务吞吐,不是 SQL 吞吐。oltp_read_write 的一个事务里包含多条 SQL,因此 TPS 和 QPS 的比例会受脚本参数影响。报告里最好同时给出 TPS、QPS、脚本名和关键脚本参数。
prepare 和 run 参数不一致
run 阶段的 --tables、--table-size 应该和 prepare 阶段保持一致。否则脚本可能访问不存在的表,或者随机 id 范围和真实数据范围不一致,导致结果失真。
数据没有预热
第一次压测往往混杂了读盘、缓存填充、JIT 编译、连接建立等开销。正式记录前可以先跑一轮短时间预热:
sysbench oltp_read_write \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-user=sbtest \
--mysql-password=sbtest \
--mysql-db=sbtest \
--tables=16 \
--table-size=100000 \
--threads=32 \
--time=60 \
run
客户端也可能成为瓶颈
sysbench 本身会消耗 CPU,尤其在高 QPS、TLS、复杂 Lua 脚本或跨机压测时。压测机的 CPU、网络、端口资源都需要监控。否则得到的可能是 sysbench 客户端瓶颈,而不是数据库瓶颈。
小结
sysbench 的优点是简单、稳定、可重复,尤其适合做数据库版本对比、参数对比和容量基线测试。但它的结果并不能直接代表真实业务,因为 workload 是人为抽象出来的。
比较可靠的使用方式是:先用 sysbench 建立数据库的基本性能边界,再结合真实业务 SQL、慢查询、线上指标做进一步验证。对源码调试来说,sysbench 的 Lua workload 也很方便,可以用很小的脚本稳定复现数据库执行路径。