【以太来袭】7. Besu 性能基线(Caliper)

赶紧趁有时间将之前的坑填完,前面几章把 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 的测试流程由两个角色协作完成:

  1. Manager(管理器):负责读取配置、部署合约、编排测试轮次、收集结果、生成报告。它是整个测试的"大脑"。
  2. 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 并等待区块确认。读操作 getview 函数,不修改状态,因此可以直接从节点本地状态读取,几乎零延迟。

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
            }
        }
    }
}

这里有几点值得展开说:

  1. 连接走 WebSocketws://):前面第 6 章说过,HTTP 不支持 eth_subscribe。而 Caliper 需要订阅新区块事件来追踪交易确认,所以必须走 WebSocket。端口是 8546,不是 8545。
  2. transactionConfirmationBlocks: 1:这意味着 Caliper 会等交易被打包进 1 个区块后才认为"确认成功"。对 QBFT 来说,1 个区块就够了,因为它是即时最终性的,不存在重组风险。
  3. gasPrice: 0:因为我们配了 min-gas-price=0,所以这里填 0。如果是公网或非零 gas 的联盟链,这里要填实际值。
  4. estimateGas: false:我是手动指定 gas limit 的(set 用 20 万,get 用 10 万),不依赖节点估算。这样避免 eth_estimateGas 调用成为性能瓶颈。
  5. 私钥明文写在配置里 :这里必须强调------这是测试环境。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 2Launching 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 决定调用合约的 setget 方法。注意 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 报错:0xd0x24......

最终结果惨不忍睹:

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。

问题出在哪?我捋了捋:

  1. 50 TPS 的发送速率超过了 QBFT 的处理能力 。我的出块间隔是 5 秒,每个块有 gas 上限。一笔 set 操作我就配置了 20 万 gas,每块 1000 万 gas 上限,理论上一个块最多塞 50 笔。但问题是...这些交易是通过 WebSocket 发的,Caliper 会等上一个交易确认了才发下一个吗?不会。Caliper 是按速率持续发射的,不管前面有没有确认。这就导致交易池被瞬间堆满,后续交易因为 nonce 冲突或者 gas 不够,直接被节点拒绝或者永远排不上队 o(╥﹏╥)o。
  2. 50 块的超时窗口不够。Calper 默认等待 50 个区块来确认交易。按 5 秒一块算,就是 250 秒(约 4 分钟)。对于正常交易来说绰绰有余,但当交易池积压严重时,某些交易可能排了 50 个块还没轮到它,就直接超时报错了。
  3. nonce 管理是个隐形杀手 。Calper 的 Worker 发交易时用的是同一个地址,nonce 必须严格递增。如果一个 Worker 发了 nonce=10 的交易但没被打包,后续 nonce=11, 12, 13... 的交易就算进了交易池也无法执行,因为以太坊要求 nonce 连续(所谓的机制问题)。这就是那个 nonce: 0xe 报错背后的原因------前面有一笔卡住了,后面全堵死了。

五、调整策略与第二次测试

有了第一次的教训,我做了 3 件事:

  1. 把 Worker 数量从 1 提到 2 。之前的配置里 workers.number: 1 只生成了一个 Manager,虽然 Manager 内部 fork 了两个 Worker,但 nonce 管理仍然受限于单进程。调整之后两个 Worker 各自独立管理自己的 nonce 序列,拥堵风险会直线下降。
  2. 把 write TPS 从 50 降到合理范围 。不设定 50 了,让 Caliper 按**固定速率模式(fixed-rate)**去跑就行。关键是...实际吞吐量不等于发送速率。发送速率只是"我每秒扔多少笔交易到网络",吞吐量才是"链每秒真正确认了多少笔"。测完看吞吐量就完了呗。
  3. 读操作大幅提速率。读操作不消耗 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 的总结经验:

  1. 别迷信发送速率。50 TPS 的发送速率不等于 50 TPS 的吞吐量。吞吐量才是你真正要关注的指标。我第一次测的时候,眼睛只盯着"我要发 50 TPS",结果交易池被撑爆了。
  2. 读和写要分开测。读操作不消耗 gas、不等待确认,所以 TPS 能跑到 200 甚至更高。如果把读写混在一起跑,读的高吞吐量会掩盖写的瓶颈。真实业务场景下,系统 90% 的瓶颈都在写操作上。
  3. QBFT 的 5 秒出块是硬天花板 。以 blockperiodseconds: 5 为例,一个小时最多出 720 个块。如果每块平均能塞 50 笔交易,那理论上限就是 36000 笔每小时、约 10 TPS(按每笔 20 万 gas 算)。如果你需要更高的写吞吐量,要么缩短出块间隔,要么提高 gas 上限,要么换共识算法。
  4. nonce 管理是联盟链压测的暗坑 。所有交易共用一个 fromAddress,nonce 必须严格递增。只要有一个 nonce 卡住,后面的全排队。所以我建议:测写性能时,用多个独立地址发送交易 。Caliper 支持配置多个 fromAddress,每个 Worker 可以绑定不同的地址,这样 nonce 就不会互相干扰。我这次只用了一个地址,下次试试多地址方案。
  5. 压测完记得看 Besu 节点的日志(大坑,不展开讲了) 。Calliper 告诉你"交易成功了",但它不告诉你节点在这期间经历了什么。我每次跑完都会去 Besu 节点上 journalctl 看一眼有没有异常------CPU 飙高、LevelDB 的 compact 操作、P2P 断连等。这些信息不进 Caliper 的报告,但对你理解真实性能瓶颈至关重要。

至此,"以太来袭"系列先告一段落。看国内用 Besu 的企业还是比较少的,希望这个系列的文章与之前 Besu 的文章能够给大家有大的帮助,也希望 Besu 在国内能够走得更远。

相关推荐
穗余12 小时前
什么是ERC-8004
人工智能·web3·区块链
Richown12 小时前
区块链开发:智能合约测试与调试技巧
区块链·react
Richown1 天前
物联网开发:MQTT与传感器数据采集
区块链·react
葫三生1 天前
《论三生原理》对《周易》《道德经》的一次根本性重写?
人工智能·算法·计算机视觉·区块链·量子计算
Richown1 天前
性能优化:前端加载性能优化指南
区块链·react
Richown1 天前
后端性能:Node.js性能优化与调优
区块链·react
Richown1 天前
无服务器架构:AWS Lambda与Serverless最佳实践
区块链·react
Richown2 天前
数据可视化:交互式图表与大屏展示
区块链·react