Sysbench 手记

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 脚本入口:观察 prepareeventcleanup 如何被调度
  • 数据库 driver:观察 sysbench.sql API 如何落到 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

这条命令会创建 sbtest1sbtest16 共 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:事务总数和 TPS
  • queries:SQL 总数和 QPS
  • latency:延迟分布,包括最小、平均、最大和 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。只要脚本暴露出 prepareeventcleanup 这些函数,就可以接入 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

均匀分布更适合测整体吞吐;偏斜分布更适合模拟热点行、热点页和缓存命中率变化。

写脚本时的注意事项

自定义脚本容易踩的坑:

  1. preparerun 使用的参数不一致,导致随机 id 范围超过真实数据范围
  2. event() 中拼接复杂字符串,客户端 CPU 成为瓶颈
  3. 每次 event 都重新连接数据库,测到的是连接开销
  4. 没有显式事务,导致每条 SQL 都自动提交
  5. 忘记关闭 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

吞吐通常会经历三个阶段:

  1. 并发增加,TPS 近似线性上涨
  2. 到达瓶颈点,TPS 增长变慢
  3. 继续增加并发,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。

这种设计带来两个好处:

  1. workload 容易改,不需要重新编译 sysbench
  2. C 框架可以复用,数据库测试、文件 IO 测试、CPU 测试都走同一套事件调度和统计逻辑

Lua 脚本通常会实现几个函数:

function prepare()
  -- 建表、导入数据
end

function event()
  -- 每个 worker 反复执行的测试事件
end

function cleanup()
  -- 清理测试对象
end

preparecleanup 是阶段性入口;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 看,整体流程可以拆成几个阶段。如果从源码角度看,关键是先区分两层:

  1. C 层的 benchmark framework:负责参数、线程、计时、统计、限速、事件调度
  2. Lua 层的 workload:负责生成 SQL、选择表、选择随机 id、调用数据库 API

大致调用关系可以理解为:

main
  ├─ 初始化全局参数和内置 test
  ├─ 解析命令行,得到 test name 和 command
  ├─ 查找 test,必要时加载 Lua 脚本
  ├─ 执行 test 的 init/prepare/run/cleanup
  └─ 输出统计结果

1. 解析命令行

sysbench 启动后先解析通用参数、测试名和命令。命令行最后的 prepareruncleanup 决定进入哪个阶段;中间的 oltp_read_write 决定加载哪个测试脚本。

典型命令:

sysbench oltp_read_write --threads=32 --time=300 run

可以理解为:

  • 测试类型:oltp_read_write
  • 并发线程:32
  • 运行时长:300 秒
  • 执行动作:run

源码里需要注意两个概念:

  • command:prepareruncleanuphelp 这类动作
  • test:cpumemoryfileiooltp_read_write 这类测试对象

命令行解析完成后,sysbench 会先确定 command,再根据 test name 找到具体的 test 实现。这个设计使得同一个 test 可以响应不同 command,例如 OLTP 脚本可以同时实现 prepareruncleanup

2. 加载测试模块

如果测试名是内置 C 模块,例如 cpumemoryfileio,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 会做两件事:

  1. 创建 Lua state,加载 sysbench 暴露给 Lua 的 API
  2. 加载用户指定的 Lua 脚本,并检查脚本里是否定义了 prepareeventcleanup 等函数

所以 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 里通常会围绕三件事展开:

  1. 节流:如果设置了 --rate,先根据事件产生速率决定是否等待
  2. 执行:调用 test 的 event 入口,Lua workload 场景就是调用 Lua event()
  3. 统计:记录 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 更接近线上体验。

常见分析顺序是:

  1. 看 error 和 reconnect,确认压测本身是否有效
  2. 看 TPS/QPS,确认吞吐是否符合预期
  3. 看 avg latency 和 p95 latency,判断响应时间
  4. 看 report interval 的趋势,确认是否存在明显抖动
  5. 结合数据库内部指标定位瓶颈

常见坑

只看 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 也很方便,可以用很小的脚本稳定复现数据库执行路径。