赶紧趁有时间将之前的坑填完,前面几章把 Besu 的部署、组件、API 都聊了一遍,那接下来必然绕不开一个问题:我这套链究竟能跑多快?
说实话,这个问题没有一个固定的答案。每次我的回答都一样------"得测"。不是敷衍,是因为区块链的性能跟共识算法、出块间隔、节点数量、网络延迟、合约复杂度全都有关系,任何一个变量变了,结论都可能不一样。所以与其拍脑袋,不如老老实实搭一套基准测试。
本次测试用的是 Hyperledger Caliper。下面把整个过程的思路、配置、翻车经历和结果都摊开来讲。
一、为什么是 Caliper
在做性能测试选型的时候,我大概对比了以下几种方案:
- 自己写脚本压测:最灵活,但统计维度要自己补。TPS、延迟分布、成功率这些都得手算,而且多轮测试的编排很麻烦。做一次两次还行,长期维护不划算。
- 用 k6 / JMeter 模拟 RPC 调用(以前做过):对 HTTP 压测很成熟,但区块链的 benchmark 不完全等于 HTTP benchmark。比如需要区分读写操作、需要控制交易发送速率和确认策略、需要知道"交易提交了"和"交易上链了"之间的延迟差。这些通用工具做不到。
- Caliper :Hyperledger 旗下的专用区块链性能测试框架。它原生支持 Ethereum 的适配器(以前版本),内置了 Manager-Worker 架构、多轮测试编排、速率控制、延迟统计和 HTML 报告生成。最关键的是------它对 Besu 的 WebSocket 连接和合约部署流程有原生支持,不用自己写适配层。
选 Caliper 还有一个很现实的原因:它跑在 Docker 里,不污染我的主机环境 。测试完 docker-compose down 一把清干净,干净利落。
当前测试我用的版本是 hyperledger/caliper:0.6.0(因为最新的版本已经不支持以太坊系列的区块链了)。
二、Caliper 的 Manager-Worker 模型
在深入配置之前,我觉得有必要先解释一下 Caliper 的工作原理,因为后面分析结果时会反复提到这些概念。
Caliper 的测试流程由两个角色协作完成:
- Manager(管理器):负责读取配置、部署合约、编排测试轮次、收集结果、生成报告。它是整个测试的"大脑"。
- Worker(工作节点):负责实际发送交易。多个 Worker 可以并行工作,每个 Worker 独立维护自己的 nonce 计数器。
一次完整的 benchmark 流程分五个阶段:
- init 阶段:初始化连接,验证网络配置。
- install 阶段:部署智能合约到目标链上。
- test 阶段:按轮次执行压测。每一轮可以有独立的速率控制、交易数量和 workload 模块。
- report 阶段:汇总所有轮次的数据,生成 HTML 报告。
- cleanup 阶段:清理资源。
Caliper 支持两种 Worker 模式:local(本地进程)和 remote(远程进程)。我这次用的是 local 模式,也就是 Manager 在自己的容器里 fork 出子进程当 Worker。对于单节点压测来说完全够用。如果是分布式压测(比如同时对多个 RPC 节点打流量),才需要用到 remote 模式配合消息队列。
还有一个很重要的概念是 SUT(System Under Test) 。在 Caliper 里,SUT 就是要测的目标系统。对于我来说,SUT 就是 Besu 网络。Caliper 通过 CALIPER_BIND_SUT=ethereum:latest 来绑定对应的适配器。这个适配器内置了 Web3.js 的连接逻辑、nonce 管理、交易签名和回执等待。
三、我的测试环境
先交代一下测试拓扑:
- Besu 网络:3 个 QBFT 验证节点 + 1 个 RPC 节点,部署在 4 台物理服务器上。
- 出块参数 :
blockperiodseconds: 5(5 秒一个块),gasLimit: 0x989680(约 1000 万 gas)。 - Gas 策略 :
min-gas-price: 0(联盟链内部零 gas,这是前面第 2 章部署时定的)。 - 共识:QBFT,即时最终性,不会分叉。
Caliper 测试机是一台独立服务器,通过 Docker Compose 启动。目录结构长这样:
plain
tests/caliper/
├── benchmarks/
│ └── benchmark-config.yaml # 测试基准配置
├── contracts/
│ ├── SimpleStorage.sol # 测试用的 Solidity 合约
│ └── SimpleStorage.json # 编译后的 ABI + Bytecode
├── networks/
│ ├── networkconfig-node1.json # 节点 1 的网络配置
│ └── networkconfig-node2.json # 节点 2 的网络配置
├── workloads/
│ └── simpleStorage.js # 工作负载模块
├── scripts/
│ ├── init.sh # 初始化脚本
│ ├── run-test.sh # 测试主控脚本
│ └── verify-setup.sh # 配置验证脚本
├── docker-compose.yml
└── .env
下面逐一过一下关键配置。
3.1 智能合约:SimpleStorage
这个合约简单到不能再简单------就是一个 set / get 的存储合约,专门给性能测试用的:
solidity
contract SimpleStorage {
uint256 private storedData;
event ValueSet(uint256 newValue, address indexed setter);
function set(uint256 x) public {
storedData = x;
emit ValueSet(x, msg.sender);
}
function get() public view returns (uint256) {
return storedData;
}
}
写操作 set 会修改链上状态、触发事件,所以需要消耗 gas 并等待区块确认。读操作 get 是 view 函数,不修改状态,因此可以直接从节点本地状态读取,几乎零延迟。
3.2 网络配置(networkconfig)
以节点 1 的配置为例:
json
{
"caliper": { "blockchain": "ethereum" },
"ethereum": {
"url": "ws://10.8.161.41:8546",
"contractDeployerAddress": "0x9400509cbbebd2b17020d1ec494b3085bc47e9c9",
"contractDeployerAddressPrivateKey": "0x...",
"fromAddress": "0x9400509cbbebd2b17020d1ec494b3085bc47e9c9",
"fromAddressPrivateKey": "0x...",
"transactionConfirmationBlocks": 1,
"timeout": 60000,
"gas": 500000,
"gasPrice": 0,
"contracts": {
"SimpleStorage": {
"path": "./contracts/SimpleStorage.json",
"gas": { "set": 200000, "get": 100000 },
"estimateGas": false
}
}
}
}
这里有几点值得展开说:
- 连接走 WebSocket (
ws://):前面第 6 章说过,HTTP 不支持eth_subscribe。而 Caliper 需要订阅新区块事件来追踪交易确认,所以必须走 WebSocket。端口是 8546,不是 8545。 transactionConfirmationBlocks: 1:这意味着 Caliper 会等交易被打包进 1 个区块后才认为"确认成功"。对 QBFT 来说,1 个区块就够了,因为它是即时最终性的,不存在重组风险。gasPrice: 0:因为我们配了min-gas-price=0,所以这里填 0。如果是公网或非零 gas 的联盟链,这里要填实际值。estimateGas: false:我是手动指定 gas limit 的(set用 20 万,get用 10 万),不依赖节点估算。这样避免eth_estimateGas调用成为性能瓶颈。- 私钥明文写在配置里 :这里必须强调------这是测试环境。Calper 的网络配置会把私钥明文存储。生产环境绝对不要这么干。如果是在生产做压测,建议用专门生成的一次性测试账户,测完就废弃。
3.3 基准配置(benchmark-config.yaml)
yaml
test:
name: Besu QBFT Performance Test
workers:
type: local
number: 1
rounds:
- label: write-operations
txNumber: 6000
rateControl:
type: fixed-rate
opts:
tps: 50
workload:
module: ./workloads/simpleStorage.js
arguments:
contractId: SimpleStorage
method: set
- label: read-operations
txNumber: 6000
rateControl:
type: fixed-rate
opts:
tps: 200
workload:
module: ./workloads/simpleStorage.js
arguments:
contractId: SimpleStorage
method: get
这个配置定义了两轮测试:
- 第一轮 :6000 笔写操作(
set),以固定速率 50 TPS 发送。 - 第二轮 :6000 笔读操作(
get),以固定速率 200 TPS 发送。
这里有个细节:workers.number: 1。这表示只有 1 个 Manager 进程(Master),但 Caliper 0.6.0 在 local 模式下会自动 fork 出 2 个 Worker 子进程来并行发交易。从后面的运行日志也能看到 Launching worker 1 of 2 和 Launching worker 2 of 2。
3.4 工作负载模块(simpleStorage.js)
javascript
const { WorkloadModuleBase } = require('@hyperledger/caliper-core');
class SimpleStorageWorkload extends WorkloadModuleBase {
async initializeWorkloadModule(workerIndex, totalWorkers, roundIndex,
roundArguments, sutAdapter, sutContext) {
await super.initializeWorkloadModule(...);
this.contractId = roundArguments.contractId;
this.method = roundArguments.method || 'set';
}
async submitTransaction() {
const value = Math.floor(Math.random() * 1000000);
if (this.method === 'set') {
const request = {
contract: this.contractId,
verb: 'set',
args: [value],
readOnly: false
};
await this.sutAdapter.sendRequests(request);
} else {
const request = {
contract: this.contractId,
verb: 'get',
args: [],
readOnly: true
};
await this.sutAdapter.sendRequests(request);
}
}
}
每次 submitTransaction 被调用时,会生成一个随机值(0 ~ 999999),然后根据 method 决定调用合约的 set 或 get 方法。注意 readOnly: true/false 这个标记是告诉 Caliper 的适配器,读操作不需要等待区块确认,可以直接返回。这个标记是性能差异的核心原因之一。
3.5 Docker Compose 编排
yaml
caliper-manager-1:
image: hyperledger/caliper:0.6.0
environment:
- CALIPER_BIND_SUT=ethereum:latest
- CALIPER_BENCHCONFIG=benchmarks/benchmark-config.yaml
- CALIPER_NETWORKCONFIG=networks/networkconfig-node1.json
volumes:
- ./networks:/hyperledger/caliper/workspace/networks:ro
- ./benchmarks:/hyperledger/caliper/workspace/benchmarks:ro
- ./workloads:/hyperledger/caliper/workspace/workloads:ro
- ./contracts:/hyperledger/caliper/workspace/contracts:ro
- ./reports:/hyperledger/caliper/workspace/reports
command: launch manager
extra_hosts:
- "host.docker.internal:host-gateway"
profiles:
- node1
这里有几个要点:
- 配置文件(networks、benchmarks、workloads、contracts)全部以 只读模式(
:ro) 挂载。这是避免容器内的误操作污染宿主机文件的好习惯。 reports目录是可写的,因为测试报告要写出来。extra_hosts配置了host.docker.internal映射,让容器能通过host.docker.internal访问宿主机的网络。如果 Besu 节点部署在 Docker 网络外的物理机上(像我这样),这个配置不是必需的,但加上也没坏处。profiles: [node1]表示这个服务只在指定 profile 时才启动。这样我一个docker-compose.yml就能管多个节点的压测,通过--profile切换目标。
配套的 Shell 脚本就不逐行解释了,核心逻辑就是 docker-compose --profile node1 up --abort-on-container-exit 启动压测、容器退出后自动停止。verify-setup.sh 用来跑前检查所有必需文件是否存在,init.sh 负责拉镜像和省去手工创建 .env 的麻烦。
四、第一次测试------翻车现场
事不宜迟马上来跑第一次。这次的配置是 写操作 50 TPS,6000 笔。这是我当时的测试配置(上面 3.3 里贴的那份)。
跑起来之后,前 60 秒日志看着挺正常:
plain
[write-operations Round 0 Transaction Info] - Submitted: 18 Succ: 0 Fail:0 Unfinished:18
[write-operations Round 0 Transaction Info] - Submitted: 44 Succ: 0 Fail:0 Unfinished:44
但到了第 12 分钟的时候,日志开始炸了(ノ`Д)ノ:
plain
error [caliper] [ethereum-connector] Failed tx on SimpleStorage; calling method: set; nonce: 0xe
Error: Transaction was not mined within 50 blocks, please make sure your
transaction was properly sent. Be aware that it might still be mined!
然后是一连串的非同 nonce 报错:0xd、0x24......
最终结果惨不忍睹:
plain
| Name | Succ | Fail | Send Rate (TPS) | Max Latency (s) | Min Latency (s) | Avg Latency (s) | Throughput (TPS) |
|------------------|------|------|-----------------|-----------------|-----------------|-----------------|------------------|
| write-operations | 25 | 25 | 5.2 | 13.08 | 3.37 | 8.22 | 0.1 |
6000 笔的目标,实际提交了 50 笔,成功了 25 笔,失败 25 笔。实际吞吐量只有 0.1 TPS。
问题出在哪?我捋了捋:
- 50 TPS 的发送速率超过了 QBFT 的处理能力 。我的出块间隔是 5 秒,每个块有 gas 上限。一笔
set操作我就配置了 20 万 gas,每块 1000 万 gas 上限,理论上一个块最多塞 50 笔。但问题是...这些交易是通过 WebSocket 发的,Caliper 会等上一个交易确认了才发下一个吗?不会。Caliper 是按速率持续发射的,不管前面有没有确认。这就导致交易池被瞬间堆满,后续交易因为 nonce 冲突或者 gas 不够,直接被节点拒绝或者永远排不上队 o(╥﹏╥)o。 - 50 块的超时窗口不够。Calper 默认等待 50 个区块来确认交易。按 5 秒一块算,就是 250 秒(约 4 分钟)。对于正常交易来说绰绰有余,但当交易池积压严重时,某些交易可能排了 50 个块还没轮到它,就直接超时报错了。
- nonce 管理是个隐形杀手 。Calper 的 Worker 发交易时用的是同一个地址,nonce 必须严格递增。如果一个 Worker 发了 nonce=10 的交易但没被打包,后续 nonce=11, 12, 13... 的交易就算进了交易池也无法执行,因为以太坊要求 nonce 连续(所谓的机制问题)。这就是那个
nonce: 0xe报错背后的原因------前面有一笔卡住了,后面全堵死了。
五、调整策略与第二次测试
有了第一次的教训,我做了 3 件事:
- 把 Worker 数量从 1 提到 2 。之前的配置里
workers.number: 1只生成了一个 Manager,虽然 Manager 内部 fork 了两个 Worker,但 nonce 管理仍然受限于单进程。调整之后两个 Worker 各自独立管理自己的 nonce 序列,拥堵风险会直线下降。 - 把 write TPS 从 50 降到合理范围 。不设定 50 了,让 Caliper 按**固定速率模式(fixed-rate)**去跑就行。关键是...实际吞吐量不等于发送速率。发送速率只是"我每秒扔多少笔交易到网络",吞吐量才是"链每秒真正确认了多少笔"。测完看吞吐量就完了呗。
- 读操作大幅提速率。读操作不消耗 gas、不修改状态、不需要等待确认。所以我把读的速率拉到 200 TPS,看看极限在哪。
第二次正式测试,我选择了节点 2(10.8.161.50)来跑。完整运行了 173 秒,两轮都成功:
plain
| Name | Succ | Fail | Send Rate (TPS) | Max Latency (s) | Min Latency (s) | Avg Latency (s) | Throughput (TPS) |
|------------------|------|------|-----------------|-----------------|-----------------|-----------------|------------------|
| write-operations | 6000 | 0 | 50.0 | 12.62 | 0.20 | 2.44 | 47.7 |
| read-operations | 6000 | 0 | 200.1 | 0.35 | 0.00 | 0.02 | 200.0 |
Benchmark finished in 173.174 seconds. Total rounds: 2. Successful rounds: 2. Failed rounds: 0.
这个结果就比较像样了。我们逐项来看:
写操作(write-operations)
- 6000 笔全部成功,0 失败。
- 发送速率 50 TPS,实际吞吐量 47.7 TPS。差值很小,说明链的处理能力跟上了发送节奏。
- 平均延迟 2.44 秒,对于一个 5 秒出块间隔的 QBFT 链来说,这个数字是合理的。交易发出后,需要等到下一个区块被打包,所以延迟大致在 0 到 5 秒之间。平均值落在 2.44 秒,说明大部分交易在 1 ~ 2 个区块内就确认了。
- 最大延迟 12.62 秒。这个对应的是某些交易刚好卡在了区块打包的边界上,多等了两个块。在区块链性能测试里,最大延迟一般会比平均值高很多,这是正常的。
- 最小延迟 0.20 秒。这个是"刚好赶上了"的情况------交易发出去的时候,下一个区块正在打包,直接就进去了。
读操作(read-operations)
- 6000 笔全部成功。
- 发送速率 200.1 TPS,实际吞吐量 200.0 TPS。几乎零损耗。
- 平均延迟 0.02 秒(20 毫秒) 。这是纯本地查询,所以极快。但实际业务里读操作不太可能都走
view函数,有些查询需要遍历历史状态,延迟会高得多。 - 最大延迟 0.35 秒。个别查询可能碰上了节点在做其他操作(比如同步状态或者 compact LevelDB),导致轻微波动。
六、从结果回看 Caliper 的底层机制
复盘整个测试过程,有几个机制值得单独拎出来讲:
6.1 速率控制(Rate Control)
我这次用的是 fixed-rate 模式,也是最简单的模式。它的工作原理是:用一个令牌桶(token bucket)每秒钟生成固定数量的令牌,Worker 每发一笔交易就去拿一个令牌,拿不到就等着。所以发送速率是严格控制的,不会出现"前面慢后面猛冲"的情况。
Caliper 还支持其他速率模式:
fixed-feedback-rate:根据未确认交易的数量动态调整发送速率,避免积压。linear-rate:从某个初始 TPS 开始,线性递增到目标 TPS。composite-rate:允许在同一个 round 里组合多个速率阶段。
对于 Besu QBFT 这种出块间隔固定的链,fixed-rate 其实就够用了。
6.2 Worker 并行机制
前面提到,Caliper 在 local 模式下会 fork 子进程当 Worker。每个 Worker 独立维护自己的 WebSocket 连接和 nonce 计数器。这意味着:
- 两个 Worker 可以同时向节点发送交易,互不阻塞。
- 但两个 Worker 共用同一个
fromAddress(也就是同一个钱包地址),所以它们的 nonce 是共享的。Calper 内部通过nonce协调机制避免两个 Worker 发出相同 nonce 的交易。 - 如果交易池积压严重,两个 Worker 的 nonce 会互相牵制------Worker A 的 nonce=10 卡住了,Worker B 的 nonce=11 也会跟着卡。所以多 Worker 并不总是提升吞吐量,有时候反而因为 nonce 竞争降低效率。这个取决于链的出块速度和交易池策略。
6.3 交易确认超时
Caliper 的 transactionConfirmationBlocks 设置为 1,意味着每笔交易只需要被打包进 1 个区块就算确认。但内部还有一个硬编码的超时机制:如果交易在 50 个区块内还未被打包,直接标记为失败 。这就是我第一天报的那个 Error: Transaction was not mined within 50 blocks 的来源。
对于 5 秒一个块的链,50 块 = 250 秒。所以如果你的链出块很慢(比如 15 秒一块,那就是 750 秒),这个超时可能要调大。那么怎么调?在网络配置里加一个 blockConfirmationTimeout 或者直接改 timeout 字段(我配的是 60000 毫秒,这个是 RPC 层面的超时,不是区块确认超时,两个不一样)。
七、写在最后
填坑的最后,这里给大家总结 5 条关于使用 Caliper 的总结经验:
- 别迷信发送速率。50 TPS 的发送速率不等于 50 TPS 的吞吐量。吞吐量才是你真正要关注的指标。我第一次测的时候,眼睛只盯着"我要发 50 TPS",结果交易池被撑爆了。
- 读和写要分开测。读操作不消耗 gas、不等待确认,所以 TPS 能跑到 200 甚至更高。如果把读写混在一起跑,读的高吞吐量会掩盖写的瓶颈。真实业务场景下,系统 90% 的瓶颈都在写操作上。
- QBFT 的 5 秒出块是硬天花板 。以
blockperiodseconds: 5为例,一个小时最多出 720 个块。如果每块平均能塞 50 笔交易,那理论上限就是 36000 笔每小时、约 10 TPS(按每笔 20 万 gas 算)。如果你需要更高的写吞吐量,要么缩短出块间隔,要么提高 gas 上限,要么换共识算法。 - nonce 管理是联盟链压测的暗坑 。所有交易共用一个
fromAddress,nonce 必须严格递增。只要有一个 nonce 卡住,后面的全排队。所以我建议:测写性能时,用多个独立地址发送交易 。Caliper 支持配置多个fromAddress,每个 Worker 可以绑定不同的地址,这样 nonce 就不会互相干扰。我这次只用了一个地址,下次试试多地址方案。 - 压测完记得看 Besu 节点的日志(大坑,不展开讲了) 。Calliper 告诉你"交易成功了",但它不告诉你节点在这期间经历了什么。我每次跑完都会去 Besu 节点上
journalctl看一眼有没有异常------CPU 飙高、LevelDB 的 compact 操作、P2P 断连等。这些信息不进 Caliper 的报告,但对你理解真实性能瓶颈至关重要。
至此,"以太来袭"系列先告一段落。看国内用 Besu 的企业还是比较少的,希望这个系列的文章与之前 Besu 的文章能够给大家有大的帮助,也希望 Besu 在国内能够走得更远。