TechEmpower Framework Benchmarks 中 Tornado 基准测试实战解析:MongoDB/motor 与 PostgreSQL/asyncpg + uvloop 双栈实现

原创2026-10-14 22:09:391,372 阅读
文章标签:性能测试测试后端开发者工具

TechEmpower Framework Benchmarks 中 Tornado 基准测试实战解析:MongoDB/motor 与 PostgreSQL/asyncpg + uvloop 双栈实现

本文深入剖析开源基准测试套件 FrameworkBenchmarks(TechEmpower Framework Benchmarks)中 Tornado 框架的测试实现。Tornado 是 Python 生态中典型的异步事件驱动 Web 框架,其在套件中以"Platform"级(自带 HTTP 服务器、不依赖反向代理)身份参与 JSON、Plaintext、数据库单查、多查、更新与 Fortune 六类基准测试。读完本文,你将掌握 Tornado 基准测试的两种完整实现方案(MongoDB + motor 与 PostgreSQL + asyncpg + uvloop)、路由与异步处理设计、基准配置字段含义、Docker 化运行方式,以及测试套件工具链对每一类端点的验证与压测机制。

Tornado 测试在 FrameworkBenchmarks 中的定位

关联文档 明确指出,Tornado 目录是 benchmarking tests suite(即 FrameworkBenchmarks 根目录)中用于比较各类 Web 开发平台性能的测试套件的一部分,该 README 提供的是"Tornado 专属信息",通用指导见测试套件文档,Python 生态的补充说明见 Python README。

从 Python README 可以看到,在 Python 框架测试矩阵中,Tornado 被用作一种高性能 HTTP/WSGI 服务器选项:文档中"Gunicorn + Tornado"一节说明,uWSGI + PyPy 难以配置、Meinheld 因重度依赖 Python/C API 在 PyPy 下并不快,因此测试使用 Tornado 作为 HTTP/WSGI 服务器,且 Tornado 支持 keep-alive,在 json 与 plaintext 基准上表现良好。而在本目录中,Tornado 更是直接作为自承载(self-hosted)的 Web 服务器参与测试,不依赖 nginx 等反向代理。

基础设施软件栈

关联文档列出了 Tornado 测试所依赖的基础设施软件,结合仓库中的 requirements_mongo.txt 与 requirements_uvloop.txt,可以确认当前仓库实际锁定的版本:

软件 用途 当前仓库锁定版本
CPython 2 / 3、PyPy Python 解释器(测试矩阵中的 flavor) Docker 基础镜像为 python:3.6.6-stretch
Tornado 异步 Web 服务器 / 框架 tornado==6.0.2
MongoDB + motor NoSQL 数据库及异步驱动 motor==2.0.0
PostgreSQL + momoko / asyncpg 关系型数据库及异步驱动 当前实现实际使用 asyncpg==0.21.0
uvloop asyncio 事件循环加速器 uvloop==0.12.2(MongoDB 版)/ uvloop==0.12.1(Postgres 版)
ujson 高性能 JSON 序列化 ujson==1.35

需要说明的适用前提:README 中同时列出 momoko 与 asyncpg 两个 PostgreSQL 异步驱动,而当前仓库的 server_uvloop.py 实际使用 asyncpg 并通过 uvloop 替换事件循环;momoko 属于历史选项,当前代码未引用。MongoDB 路径则使用 motor 的 MotorClient。所有版本以仓库当前锁定内容为准。

两种基准实现:MongoDB/motor 与 PostgreSQL/asyncpg

Tornado 目录下有两个独立的服务器入口文件,对应两条不同的数据访问链路,并由 benchmark_config.json 与 config.toml 注册为不同测试名。

MongoDB + motor 版本(server.py)

server.py 是默认测试(数据库为 MongoDB)的实现,完整可运行。其核心设计如下。

命令行参数通过 tornado.options 定义(L15-L17):

options.define('port', default=8888, type=int, help="Server port")
options.define('mongo', default='localhost', type=str, help="MongoDB host")
options.define('backlog', default=8192, type=int, help="Server backlog")

注意:tornado.dockerfile 启动时以 --port=8080 覆盖默认值 8888,以 --mongo=tfb-database 指向基准网络中的数据库容器别名。

数据访问基于 motor.MotorClient 的 hello_world 数据库与 world 集合,配合 asyncio.wait 做并发聚合。单查询与多查询核心逻辑如下:

class SingleQueryHandler(JsonHandler):

    async def get(self):
        world = await db.world.find_one(randint(1, 10000))
        world = {self.ID: int(world['_id']),
                 self.RANDOM_NUMBER: int(world[self.RANDOM_NUMBER])
                 }
        response = json.dumps(world)
        self.finish(response)


class MultipleQueriesHandler(JsonHandler):
    async def get(self):
        try:
            queries = int(self.get_argument(self.QUERIES))
        except Exception:
            queries = 1

        if queries < 1:
            queries = 1
        elif queries > 500:
            queries = 500

        worlds = []
        futures, _ = await asyncio.wait([db.world.find_one(randint(1, 10000)) for _ in range(queries)])

        for future in futures:
            world = future.result()
            worlds.append({self.ID: int(world['_id']),
                    self.RANDOM_NUMBER: int(world[self.RANDOM_NUMBER])})

        self.finish(json.dumps(worlds))

关键设计点:

  • queries 参数钳制:解析失败时回退为 1;小于 1 按 1 处理,大于 500 按 500 处理,与测试工具链中 verify_query_cases 对 0、501 等边界用例的校验口径一致(见下文工具链章节)。
  • 并发拉取:多查询使用 asyncio.wait 一次性发起 N 个 find_one 协程,最大化并发而非串行 await。
  • 更新测试(UpdateHandler,L56-L81):先并发读取 N 行,随后构造 N 个 update_one 操作,再次通过 asyncio.wait(updates) 并发执行写入,返回的是更新前的 randomNumber 快照。
  • Fortune 测试(FortuneHandler,L84-L90):通过异步生成器推导式 [fortune async for fortune in db.fortune.find()] 读取全部记录,追加一行 {'id': 0, 'message': 'Additional fortune added at request time.'},再按 message 排序后渲染模板。

路由注册与启动方式(L93-L115):

application = tornado.web.Application([
    (r"/json", JsonHelloWorldHandler),
    (r"/plaintext", PlaintextHelloWorldHandler),
    (r"/db", SingleQueryHandler),
    (r"/queries", MultipleQueriesHandler),
    (r"/updates", UpdateHandler),
    (r"/fortunes", FortuneHandler),
],
    template_path="templates"
)

application.ui_modules = {}

if __name__ == "__main__":
    uvloop.install()
    tornado.options.parse_command_line()
    server = tornado.httpserver.HTTPServer(application)
    server.bind(options.port, backlog=options.backlog, reuse_port=True)
    server.start(0)

    ioloop = tornado.ioloop.IOLoop.instance()
    db = motor.MotorClient(options.mongo, maxPoolSize=500).hello_world
    ioloop.start()

启动细节值得注意:

  • uvloop.install() 在 __main__ 中先行调用,将 Tornado 底层的 asyncio 事件循环替换为 uvloop 实现。
  • server.start(0) 表示按 CPU 核心数派生等价数量的 worker 进程;reuse_port=True 使多进程共享同一端口(SO_REUSEPORT),这是套件中 Tornado 利用多核的核心手段。
  • motor.MotorClient(..., maxPoolSize=500) 将 MongoDB 连接池上限调至 500,以支撑高并发基准。
  • application.ui_modules = {} 显式清空 UI 模块,避免不必要的模板模块加载开销。

PostgreSQL + asyncpg + uvloop 版本(server_uvloop.py)

server_uvloop.py 对应 benchmark_config.json 中的 uvloop 测试(数据库为 Postgres,display_name 为 "Tornado [uvloop]")。它使用 asyncpg 连接池与预编译语句(prepared statement)完成数据访问:

READ_ROW_SQL = 'SELECT "randomnumber", "id" FROM "world" WHERE id = $1'
WRITE_ROW_SQL = 'UPDATE "world" SET "randomnumber"=$1 WHERE id=$2'

async def init_db_pool():
    return await asyncpg.create_pool(
        host="tfb-database",
        user="benchmarkdbuser",
        password="benchmarkdbpass",
        database="hello_world")

def get_num_queries(request):
    try:
        query_count = int(request.get_query_argument('queries'))
    except (KeyError, IndexError, ValueError):
        return 1

    if query_count < 1:
        return 1
    if query_count > 500:
        return 500
    return query_count

与 MongoDB 版本不同,该实现通过 self.application.pool.acquire() 获取连接,并显式区分 fetchval(单值)与 fetch(多行)。多查询与更新均使用 connection.prepare() 预编译语句复用,避免重复解析 SQL:

class MultipleQueriesHandler(JsonHandler):
    async def get(self):
        num_queries = get_num_queries(self)
        row_ids = [randint(1, 10000) for _ in range(num_queries)]
        worlds = []

        async with self.application.pool.acquire() as connection:
            statement = await connection.prepare(READ_ROW_SQL)
            for row_id in row_ids:
                number = await statement.fetchval(row_id)
                worlds.append({'id': row_id, 'randomNumber': number})
        self.write_response(worlds)


class UpdateHandler(JsonHandler):
    async def get(self):
        num_queries = get_num_queries(self)
        updates = [(randint(1, 10000), randint(1, 10000)) for _ in range(num_queries)]
        worlds = [{'id': row_id, 'randomNumber': number} for row_id, number in updates]

        async with self.application.pool.acquire() as connection:
            statement = await connection.prepare(READ_ROW_SQL)
            for row_id, number in updates:
                await statement.fetchval(row_id)
            await connection.executemany(WRITE_ROW_SQL, updates)

        self.write_response(worlds)

数据库连接在事件循环启动时通过 ioloop.run_sync(partial(setup_database)) 建立并挂载到 app.pool(L136-L148)。其凭据可通过 PGUSER、PGPASS 环境变量覆盖(默认 benchmarkdbuser / benchmarkdbpass,库名 hello_world,主机 tfb-database,端口 5432),与 toolset/databases/postgres/postgres.py 中工具链侧连接参数保持一致。

共享工具类:commons.py 与响应头设计

commons.py 定义了测试各端点公用的 Handler 基类与 Hello World 处理器,是两套实现共用的响应规范来源:

class BaseHandler(tornado.web.RequestHandler):
    RANDOM_NUMBER = "randomNumber"
    ID = "id"
    QUERIES = "queries"

    def compute_etag(self):
        return None

class PlainHandler(BaseHandler):
    def set_default_headers(self):
        self.set_header("Content-Type", "text/plain")

class HtmlHandler(BaseHandler):
    def set_default_headers(self):
        self.set_header("Content-Type", "text/html; charset=UTF-8")

class JsonHandler(BaseHandler):
    def set_default_headers(self):
        self.set_header("Content-Type", "application/json")

class PlaintextHelloWorldHandler(PlainHandler):
    HELLO_WORLD = b"Hello, World!"

    def get(self):
        self.finish(self.HELLO_WORLD)

class JsonHelloWorldHandler(JsonHandler):
    def get(self):
        obj = json.dumps({"message": "Hello, World!"})
        self.finish(obj)

设计要点:

  • 重写 compute_etag 返回 None:禁用 Tornado 默认的 ETag 计算,避免基准测试中额外的内容哈希开销。
  • set_default_headers 按类型固定 Content-Type:JSON 端点返回 application/json,纯文本端点返回 text/plain,HTML 端点返回 text/html; charset=UTF-8,与工具链 verify_headers(..., should_be='json'/'plaintext'/'html') 的断言一一对应。
  • 字段常量集中定义:id、randomNumber、queries 三个响应/请求字段名作为类常量,保证 Mongo 与 Postgres 两条实现输出格式一致(JSON 键 id / randomNumber 均为整数)。

模板:Fortunes 渲染

templates 目录下有两个模板,分别对应两条实现的数据形态:

  • fortunes.html(MongoDB 版):fortune 以字典形式传入,模板用 {{ int(fortune['id']) }} 与 {{ fortune['message'] }} 输出;
  • fortunes_asyncpg.html(Postgres 版):fortune 以列表 [id, message] 形式传入,模板用 {{ fortune[0] }} 与 {{ fortune[1] }} 输出。

两者均使用 {% whitespace oneline %} 指令压缩空白,并输出 <table> 结构:<tr><th>id</th><th>message</th></tr> 表头加 12 行数据(11 条库内数据 + 1 条请求时附加数据)。该结构与工具链中 fortune_html_parser.py 内置的 valid_fortune 规范字符串(含 <script>alert(...) 转义数据、Unicode 数据等 12 行)精确对应,任何多余标签(如 <meta>、<tbody>)都会被判定为无效并输出 diff。

基准配置:benchmark_config.json 与 config.toml

benchmark_config.json

benchmark_config.json 定义了套件元数据扫描器(Metadata.gather_tests,见 toolset/utils/metadata.py)读取的测试清单,包含两个测试:

  • default:database = MongoDB,webserver = Tornado,json_url=/json、db_url=/db、query_url=/queries?queries=、plaintext_url=/plaintext、fortune_url=/fortunes、update_url=/updates?queries=,端口 8080;
  • uvloop:database = Postgres,display_name = "Tornado [uvloop]",端口 8080。

公共元数据字段包括 approach: "Realistic"(真实场景部署方式)、classification: "Platform"(自带平台级服务器)、orm: "Raw"(裸 SQL/原生驱动,无 ORM)、language: "Python"、flavor: "Python3"、webserver: "Tornado"、os/database_os: "Linux"。

config.toml

config.toml 是另一套(测试名扩展矩阵更细)的声明文件,注册了 5 个测试:

  • main:MongoDB + Tornado;
  • py3-uvloop:Postgres + uvloop;
  • py3:MongoDB;
  • postgresql-raw:Postgres(不注册 fortune/update 端点);
  • pypy2:MongoDB(面向 PyPy2 解释器的历史矩阵项)。

两者 URL 定义一致(/plaintext、/json、/db、/queries?queries=、/updates?queries=、/fortunes)。注意 URL 必须满足工具链的最小长度校验:plaintext 路由至少 10 字符、query 路由至少 9 字符、update 至少 8 字符、fortune 至少 9 字符、json 至少 5 字符、db 至少 3 字符(见下文各测试类型校验实现),当前配置全部满足。

容器化构建与运行

两个 Dockerfile 结构对称,均基于 python:3.6.6-stretch:

  • tornado.dockerfile(MongoDB 版):
    FROM python:3.6.6-stretch
    ADD ./ /tornado
    WORKDIR /tornado
    RUN pip3 install -r /tornado/requirements_mongo.txt
    EXPOSE 8080
    CMD python3 server.py --port=8080 --mongo=tfb-database --logging=error
    
  • tornado-uvloop.dockerfile(Postgres 版):
    FROM python:3.6.6-stretch
    ADD ./ /tornado
    WORKDIR /tornado
    RUN pip3 install -r /tornado/requirements_uvloop.txt
    EXPOSE 8080
    CMD python3 server_uvloop.py --logging=error
    

要点:

  • --logging=error 抑制访问日志,避免压测期间 I/O 干扰。
  • 容器默认端口 8080 与配置文件中 port: 8080 一致;server_uvloop.py 将 port 默认值定义为 8080,而 server.py 默认 8888 但由 Dockerfile CMD 显式覆盖为 8080。
  • MongoDB 版通过 --mongo=tfb-database 指向测试网络中的数据库服务别名;Postgres 版硬编码 host="tfb-database" 并从环境变量读取凭据。
  • 依赖安装由 requirements_mongo.txt(tornado 6.0.2、motor 2.0.0、uvloop 0.12.2、ujson 1.35)与 requirements_uvloop.txt(asyncpg 0.21.0、tornado 6.0.2、ujson 1.35、uvloop 0.12.1)驱动。

工具链验证与压测机制

FrameworkBenchmarks 的测试流程由 toolset/benchmark/benchmarker.py 编排:先构建 wrk 压测镜像与所需数据库镜像,再对每个测试依次执行"构建 Docker 镜像 → 运行容器 → 验证 URL → 压测 → 清理",验证与压测分离。

六类端点的验证逻辑

每个测试类型对应 toolset/test_types/ 下的一个模块,Tornado 的六个端点逐一对应:

  • json(json.py):要求响应为 JSON 对象且含 message 键(verify_helloworld_object),Content-Type 为 JSON,路由至少 5 字符。
  • plaintext(plaintext.py):响应体必须包含 hello, world!(不区分大小写),且额外字节会触发 warn(提示多余输出影响性能),Content-Type 为 text/plain,路由至少 10 字符。Tornado 的 HELLO_WORLD = b"Hello, World!" 精确命中该校验。
  • db(db.py):要求 JSON 对象含 id 与 randomNumber 两个整数字段(verify_randomnumber_object),返回数组会触发 warn;随后通过 verify_queries_count 统计数据库实际查询次数,验证恰为并发数 × 1 次。
  • query(query.py):用 ('2','fail'), ('0','fail'), ('foo','fail'), ('501','warn'), ('','fail') 五组边界用例调用 verify_query_cases,验证返回数组长度与键类型。Tornado 对 <1 按 1、>500 按 500 的钳制逻辑与该口径一致。
  • update(update.py):与 query 相同边界用例,但 verify_query_cases(..., True) 要求服务端真实发生数据库写入,返回更新前快照。
  • fortune(fortune.py):通过 FortuneHTMLParser 将响应规范化后与内置 valid_fortune 字符串做严格相等比对(忽略 meta/link/script/thead/tbody 标签,并归一化字符实体),同时校验查询次数为并发数 × 12 行。

压测脚本

压测统一使用 wrk 脚本,由测试类型决定:

  • concurrency.sh:用于 json、db、fortune 等,按 concurrency_levels 逐级加压;
  • pipeline.sh:用于 plaintext,启用 HTTP pipeline(pipeline 深度 16)衡量纯吞吐;
  • query.sh:用于 query/update,先跑 primer 与 warmup,再对每个 query_levels 执行正式压测。

以 query.sh 为例,其命令形态为:

wrk -H "Host: $server_host" -H "Accept: $accept" -H "Connection: keep-alive" \
    --latency -d $duration -c $max_concurrency --timeout 8 -t $max_threads "$url$c"

Tornado 的 keep-alive 支持使其无需反向代理即可直接承受该类长连接压测。

如何运行 Tornado 基准测试

在仓库根目录通过 toolset/run-tests.py 入口运行测试套件(-t 指定测试目录):

# 仅验证 Tornado 测试的 URL 与响应正确性(不压测)
python3 toolset/run-tests.py --test tornado --mode verify

# 对 Tornado 执行完整基准(构建镜像、验证、wrk 压测、解析结果)
python3 toolset/run-tests.py --test tornado --mode benchmark

# 限定测试类型(如只跑 db 与 query)
python3 toolset/run-tests.py --test tornado --type db,query --mode benchmark

常用参数说明(源自 run-tests.py 的 argparse 定义):

  • --test:指定测试名(框架目录名),如 tornado;
  • --type:指定测试类型(json、plaintext、db、query、update、fortune、cached-query,或 all);
  • --mode:verify(仅验证)、benchmark(完整压测)、audit(审计框架配置一致性)、new(脚手架生成新框架测试);
  • --duration / --concurrency-levels / --query-levels:压测时长(秒)与并发、查询数梯度(支持 1,5,10 或 start:step:end 序列语法);
  • --server-host / --database-host / --client-host:三机部署时指定各角色主机;
  • --network-mode:默认 bridge 网络 tfb;传 host 时为三机 TCP Docker 通信模式(见 benchmark_config.py)。

执行前需确保本机具备 Docker 环境与 wrk 构建条件;--audit 模式可用于先行校验 Tornado 配置的字段完整性(URL 长度、数据库类型、元数据等)。

小结

Tornado 在 FrameworkBenchmarks 中的实现是一套"异步驱动 + uvloop 事件循环 + 多进程复用端口"的典型范例:MongoDB 路径借助 motor 与 asyncio.wait 实现高并发读取,PostgreSQL 路径借助 asyncpg 连接池与预编译语句控制连接与 SQL 开销;两条路径共享同一套响应规范(commons.py)与配置元数据,并接受工具链六类测试类型的严格验证。阅读源码时建议从 server.py 与 server_uvloop.py 入手,配合 benchmark_config.json、config.toml 与两个 Dockerfile 理解测试矩阵,再对照 toolset/test_types/ 下的验证实现理解每一处响应细节的设计动机。

登录后查看全文
FrameworkBenchmarks