TechEmpower Framework Benchmarks 中 Tornado 基准测试实战解析:MongoDB/motor 与 PostgreSQL/asyncpg + uvloop 双栈实现
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/ 下的验证实现理解每一处响应细节的设计动机。