长安链运维与压测手册
这是一份命令级操作手册,按场景整理长安链集群的运维、链操作与压测:Ansible 批量运维、节点与分片管理、CMC 命令行(链配置 / 合约 / 系统合约 / 链上查询)、压测与数据准备、日志分析、性能调优、辅助工具。原《CMC 命令行工具使用指南》已并入 CMC 章节,后续新的操作命令继续往这里加。
Ansible 批量运维
文件分发
ansible shard_servers -m copy -a "src=./shard.tgz dest=/data"
ansible shard_servers -m copy -a "src=./shard_cmc.tgz dest=/data"
全局连通性检查
节点与分片管理
二进制部署与校验(集群)
# 分发新编译的 chainmaker 二进制
ansible shard_servers -m copy -a "src=./chainmaker dest=/data mode=0755"
# 校验 MD5
ansible shard_servers -m shell -a "cd /data && md5sum chainmaker" \
| grep "<md5_hash>" | wc -l
# 逐节点替换二进制
ansible shard_servers -m shell -a "cd /data && \cp -f chainmaker shard/chainmaker-v2.3.7-node1/bin/"
ansible shard_servers -m shell -a "cd /data && \cp -f chainmaker shard/chainmaker-v2.3.7-node2/bin/"
ansible shard_servers -m shell -a "cd /data && \cp -f chainmaker shard/chainmaker-v2.3.7-node3/bin/"
ansible shard_servers -m shell -a "cd /data && \cp -f chainmaker shard/chainmaker-v2.3.7-node4/bin/"
# 批量校验替换结果
ansible shard_servers -m shell -a \
"cd /data && md5sum shard/chainmaker-v2.3.7-node*/bin/chainmaker" \
| grep "<md5_hash>" | wc -l
本地换包:批量替换 + MD5 校验
不上远程、只在本地换二进制时(例如 Mac 上跑四节点链,先把发布包里的二进制换成 darwin-arm64 产物),for + cp 一条命令批量替换,md5sum 一条命令校验(在 chainmaker-go/scripts 下执行):
# 批量替换:四个 org 包的 bin/chainmaker 换成 bin/ 下的 darwin-arm64 产物
for f in ../build/release/chainmaker-v2.4.0_beta-wx-org*/bin/chainmaker; do
cp -f ../bin/chainmaker-v2.4.0-darwin-arm64 "$f"
done
# 校验:源文件与四份副本一起算 md5,5 行哈希一致才算替换成功
md5sum /data/workspace/dev/chainmaker-go/bin/chainmaker-v2.4.0-darwin-arm64 \
/data/workspace/dev/chainmaker-go/build/release/chainmaker-v2.4.0_beta-wx-org*/bin/chainmaker
org*由 shell 在循环前展开成 4 个路径;一个都没匹配到会直接报错(bash 默认没有nullglob),版本号升级时路径里的v2.4.0_beta要同步改cp -f覆盖不提示(压过交互式 shell 里的cp -i别名);目标已存在时只替换内容,可执行权限保留- 校验列表必须带上源文件——它是基准线;只比四份副本互相一致,证明不了它们来自目标文件
- 只要一个判定结果:
md5sum … | awk '{print $1}' | sort -u | wc -l,输出1即全部一致 - macOS 上没有
md5sum(用md5或shasum -a 256);file bin/chainmaker出现Mach-O+arm64才算选对产物
解压部署包
ansible shard_servers -m shell -a "cd /data && tar zxvf shard.tgz"
ansible shard_servers -m shell -a "cd /data && tar zxvf shard_cmc.tgz"
快速初始化分片
# 1. 停止所有 chainmaker 进程
ansible shard_servers -m shell -a "pkill chainmaker"
# 2. 清除旧数据
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node* -name "data" -type d \
-execdir bash -c "rm -rf data" \;'
# 3. 清理日志
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node* -name "log" -type d \
-execdir bash -c "rm -rf log" \;'
# 4. 从备份恢复初始数据
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node* -name "data.bak" -type d \
-execdir bash -c "cp -rf data.bak data" \;'
# 5. 批量启动
ansible shard_servers -m shell -a \
"find /data/shard/chainmaker-v2.3.7-node*/bin -name 'start.sh' \
-execdir bash -c './start.sh' \;"
# 6. 等待启动完成后检查状态
sleep 10
ansible-playbook .ansible/playbooks/check_sfc_status.yml \
-e "target=shard_servers" -e "soft='chainmaker start'" -e "count=4"
备份分片数据
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node* -name "data" -type d \
-execdir bash -c "cp -rf data data.bak" \;'
重启节点
# 方式一:kill + start
ansible shard_servers -m shell -a "pkill chainmaker"
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/bin/ -name "start.sh" \
-execdir bash -c "./start.sh" \;'
# 方式二:restart
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/bin/ -name "restart.sh" \
-execdir bash -c "./restart.sh" \;'
# 方式三:stop
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/bin/ -name "stop.sh" \
-execdir bash -c "./stop.sh" \;'
CMC · 链配置管理
查询链配置
# 查询 WAL 写入模式
ansible shard_servers -m shell -a \
"cd /data/shard_cmc && ./cmc client chainconfig query \
--sdk-conf-path=./testdata/sdk_config_pk.yml | grep -A 1 'WAL_write_mode'"
# 查询区块交易容量
ansible shard_servers -m shell -a \
"cd /data/shard_cmc && ./cmc client chainconfig query \
--sdk-conf-path=./testdata/sdk_config_pk.yml | grep -A 1 'block_tx_capacity'"
更新区块参数
# 更新区块参数(invoke CHAIN_CONFIG / BLOCK_UPDATE)
./cmc client contract user invoke \
--contract-name=CHAIN_CONFIG \
--method=BLOCK_UPDATE \
--sdk-conf-path=./testdata/sdk_config_pk.yml \
--params="{\"block_tx_capacity\":\"2000\",\"tx_timestamp_verify\":\"true\",\"tx_timeout\":\"600\",\"block_size\":\"200\",\"block_interval\":\"20\",\"tx_parameter_size\":\"50\"}" \
--admin-key-file-paths=./testdata/crypto-config/node1/admin/admin1/admin1.key,./testdata/crypto-config/node1/admin/admin2/admin2.key,./testdata/crypto-config/node1/admin/admin3/admin3.key \
--sync-result=true \
--result-to-string=true
# 集群批量:更新区块交易容量为 5000
ansible shard_servers -m shell -a \
"cd /data/shard_cmc && ./cmc client chainconfig block update \
--sdk-conf-path=./testdata/sdk_config_pk.yml \
--admin-key-file-paths=./testdata/crypto-config/node1/admin/admin1/admin1.key,./testdata/crypto-config/node1/admin/admin2/admin2.key,./testdata/crypto-config/node1/admin/admin3/admin3.key \
--block-tx-capacity 5000"
共识扩展配置
# 设置 WAL 写入模式
./cmc client chainconfig consensusextra add \
--extra-config-key=WAL_write_mode \
--extra-config-value="2" \
--sdk-conf-path=./testdata/sdk_config_pk.yml \
--admin-key-file-paths=./testdata/crypto-config/node1/admin/admin1/admin1.key,./testdata/crypto-config/node1/admin/admin2/admin2.key,./testdata/crypto-config/node1/admin/admin3/admin3.key
CMC · 合约管理
创建合约
./cmc client contract user create \
--contract-name=fact \
--runtime-type=DOCKER_GO \
--byte-code-path=/data/workspace/prod/contracts-go/fact/fact.7z \
--version=1.0 \
--sdk-conf-path=./testdata/sdk_config.yml \
--admin-key-file-paths=./testdata/crypto-config/wx-org1.chainmaker.org/user/admin1/admin1.tls.key,./testdata/crypto-config/wx-org2.chainmaker.org/user/admin1/admin1.tls.key,./testdata/crypto-config/wx-org3.chainmaker.org/user/admin1/admin1.tls.key,./testdata/crypto-config/wx-org4.chainmaker.org/user/admin1/admin1.tls.key \
--admin-crt-file-paths=./testdata/crypto-config/wx-org1.chainmaker.org/user/admin1/admin1.tls.crt,./testdata/crypto-config/wx-org2.chainmaker.org/user/admin1/admin1.tls.crt,./testdata/crypto-config/wx-org3.chainmaker.org/user/admin1/admin1.tls.crt,./testdata/crypto-config/wx-org4.chainmaker.org/user/admin1/admin1.tls.crt \
--sync-result=true \
--params="{}"
证书模式说明
证书模式需要同时提供 --admin-key-file-paths 和 --admin-crt-file-paths,多个组织的管理员密钥和证书用逗号分隔。
./cmc client contract user create \
--contract-name=fact \
--runtime-type=DOCKER_GO \
--byte-code-path=/data/workspace/prod/contracts-go/fact/fact.7z \
--version=1.0 \
--sdk-conf-path=./testdata/sdk_config_pk.yml \
--admin-key-file-paths=./testdata/crypto-config/node1/admin/admin1/admin1.key \
--sync-result=true
公钥模式简化
公钥模式只需提供管理员私钥,无需证书文件,配置更简洁。
CMC · 系统合约
系统合约提供了链级别的原生功能,包括获取用户地址、铸币、转账和余额查询。
获取用户地址
./cmc cert userAddr \
--pubkey-cert-path=./testdata/crypto-config/node2/user/client1/client1.pem \
--sdk-conf-path=./testdata/sdk_config_pk.yml
示例地址:
| 节点 | 地址 |
|---|---|
| node1:client1 | BmUAa6vKFSu1fP6Zi6FbzP9fZwWwFLjVfoHCc34n87hV |
| node2:client2 | 8CqhsVoX5symw3H9r723pgXGAkEcDgwvVHc4zzrcUptZ |
铸币操作
转账操作
./cmc client contract system transfer \
--amount=1 \
--address=8CqhsVoX5symw3H9r723pgXGAkEcDgwvVHc4zzrcUptZ \
--sdk-conf-path=./testdata/sdk_config_pk.yml \
--chain-id=chain1 \
--user-signkey-file-path=./testdata/crypto-config/node1/user/client1/client1.key
查询余额
# 单节点查询
./cmc client contract system balance-of \
--address=01qhsVoX5symw3H9r723pgXGAkEcDgwvVHc4zzrcUptZ \
--sdk-conf-path=./testdata/sdk_config_pk.yml \
--chain-id=chain1 \
--user-signkey-file-path=./testdata/crypto-config/node1/user/client1/client1.key
# 集群批量查询
ansible shard_servers -m shell -a \
"cd /data/shard_cmc && ./cmc client contract system balance-of \
--address=0854455354000800000000fe8e26b8b645ee1ed5 \
--sdk-conf-path=./testdata/sdk_config_pk.yml \
--chain-id=chain1 \
--user-signkey-file-path=./testdata/crypto-config/node1/user/client1/client1.key"
CMC · 用户合约
DPOS_ERC20 合约
DPOS_ERC20 是长安链的 ERC20 代币合约实现,支持铸币、转账和余额查询。
铸币
./cmc client contract user invoke \
--contract-name=DPOS_ERC20 \
--method=MINT \
--sdk-conf-path=./testdata/sdk_config_pk.yml \
--params='{"contract_name":"DPOS_ERC20","shard_num":"2","topic":"topic","to":"00qhsVoX5symw3H9r723pgXGAkEcDgwvVHc4zzrcUptZ","value":"1000000000"}' \
--admin-key-file-paths=./testdata/crypto-config/node1/admin/admin1/admin1.key,./testdata/crypto-config/node1/admin/admin2/admin2.key,./testdata/crypto-config/node1/admin/admin3/admin3.key \
--sync-result=true \
--result-to-string=true
转账
./cmc client contract user invoke \
--contract-name=DPOS_ERC20 \
--method=TRANSFER_FROM \
--sdk-conf-path=./testdata/sdk_config_pk.yml \
--params='{"contract_name":"DPOS_ERC20","shard_num":"2","topic":"topic","from":"00qhsVoX5symw3H9r723pgXGAkEcDgwvVHc4zzrcUptZ","to":"01qhsVoX5symw3H9r723pgXGAkEcDgwvVHc4zzrcUptZ","value":"1"}' \
--admin-key-file-paths=./testdata/crypto-config/node1/admin/admin1/admin1.key,./testdata/crypto-config/node1/admin/admin2/admin2.key,./testdata/crypto-config/node1/admin/admin3/admin3.key \
--sync-result=true \
--result-to-string=true
查询余额
./cmc client contract user invoke \
--contract-name=DPOS_ERC20 \
--method=GET_BALANCEOF \
--sdk-conf-path=./testdata/sdk_config_pk.yml \
--params='{"contract_name":"DPOS_ERC20","shard_num":"1","topic":"topic","owner":"0154455354000100000000d238e34df68b973112","to":"01qhsVoX5symw3H9r723pgXGAkEcDgwvVHc4zzrcUptZ","value":"1"}' \
--admin-key-file-paths=./testdata/crypto-config/node1/admin/admin1/admin1.key,./testdata/crypto-config/node1/admin/admin2/admin2.key,./testdata/crypto-config/node1/admin/admin3/admin3.key \
--sync-result=true \
--result-to-string=true
存证合约(fact)
存证合约用于将文件哈希等信息上链存证。
保存存证
./cmc client contract user invoke \
--contract-name=fact \
--method=save \
--sdk-conf-path=./testdata/sdk_config_pk.yml \
--params='{"file_name":"name007","file_hash":"ab3456df5799b87c77e7f88","time":"6543234"}' \
--sync-result=true
查询存证
./cmc client contract user invoke \
--contract-name=save_04 \
--method=findByFileHash \
--params='{"evidence_type":"test","version":"1.2","file_hash":"aaaaaa","file_name":"bbb","time":"123"}' \
--sdk-conf-path=./testdata/sdk_config.yml \
--admin-key-file-paths=./testdata/crypto-config/wx-org1.chainmaker.org/user/admin1/admin1.tls.key,./testdata/crypto-config/wx-org2.chainmaker.org/user/admin1/admin1.tls.key,./testdata/crypto-config/wx-org3.chainmaker.org/user/admin1/admin1.tls.key \
--admin-crt-file-paths=./testdata/crypto-config/wx-org1.chainmaker.org/user/admin1/admin1.tls.crt,./testdata/crypto-config/wx-org2.chainmaker.org/user/admin1/admin1.tls.crt,./testdata/crypto-config/wx-org3.chainmaker.org/user/admin1/admin1.tls.crt \
--sync-result=true
CMC · 链上查询
查询区块
# 按高度查询(证书模式)
./cmc query block-by-height 5748 --chain-id=chain1 \
--sdk-conf-path=./testdata/sdk_config.yml
# 按高度查询(公钥模式)
./cmc query block-by-height 1122 --chain-id=chain1 \
--sdk-conf-path=./testdata/sdk_config_pk.yml
# 不带读写集
./cmc query block-by-height 156434 --chain-id=chain1 \
--sdk-conf-path=./testdata/sdk_config_pk.yml --with-rw-set=false
查询交易
./cmc query tx chain1_f2d4ac387116407005584a5cee380145f2d4ac387116407005584a5_0 \
--sdk-conf-path=./testdata/sdk_config_pk.yml
查询交易池
# 单节点查询
./cmc txpool status --sdk-conf-path ./testdata/sdk_config.yml
./cmc txpool status --sdk-conf-path ./testdata/sdk_config_pk.yml
# 实时监控交易池
watch -n 0.1 'cd /data/shard_cmc && ./cmc txpool status \
--sdk-conf-path=./testdata/sdk_config_pk.yml'
# 集群批量查询
ansible shard_servers -m shell -a \
"cd /data/shard_cmc && ./cmc txpool status \
--sdk-conf-path=./testdata/sdk_config_pk.yml"
配置项批量变更
chainmaker.yml 配置
一致性引擎(TBFT)
# 启用 tbft 配置块
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec sed -i "s/# tbft:/tbft:/g" {} \;'
# 设置 broadcaster_interval 为 6000
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec sed -i \
"/tbft:/, +3 {/# broadcaster_interval: 1000/s/# broadcaster_interval: 1000/broadcaster_interval: 6000/}" {} \;'
# 验证
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec grep -A 3 "tbft:" {} \;'
交易过滤器与分片
# tx_filter type: 0 -> 3
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec sed -i \
"/tx_filter:/, +6 {/type:/s/0/3/}" {} \;'
# sharding type: 0 -> 1
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec sed -i \
"/sharding:/, +30 {/type:/s/0/1/}" {} \;'
# sharding interval: 10 -> 600
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec sed -i \
"/sharding:/, +30 {/interval:/s/10/600/}" {} \;'
存储与性能优化
# 关闭慢日志
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec sed -i "s/slow_log: 2/slow_log: 0/g" {} \;'
# 启用 RWC 缓存
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec sed -i "s/enable_rwc: false/enable_rwc: true/g" {} \;'
# 扩大滚动窗口缓存容量
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec sed -i \
"s/rolling_window_cache_capacity: 600000/rolling_window_cache_capacity: 1000000/g" {} \;'
logdb_segment_size 差异化配置
# 先统一设置基准值
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec sed -i \
"s/logdb_segment_size: 368/logdb_segment_size: 10240/g" {} \;'
# 各节点差异化配置(对比测试用)
ansible shard_servers -m shell -a \
'cd /data/shard && sed -i "s/logdb_segment_size: 10240/logdb_segment_size: 200/g" \
chainmaker-v2.3.7-node1/config/node1/chainmaker.yml'
ansible shard_servers -m shell -a \
'cd /data/shard && sed -i "s/logdb_segment_size: 10240/logdb_segment_size: 256/g" \
chainmaker-v2.3.7-node2/config/node2/chainmaker.yml'
ansible shard_servers -m shell -a \
'cd /data/shard && sed -i "s/logdb_segment_size: 10240/logdb_segment_size: 312/g" \
chainmaker-v2.3.7-node3/config/node3/chainmaker.yml'
ansible shard_servers -m shell -a \
'cd /data/shard && sed -i "s/logdb_segment_size: 10240/logdb_segment_size: 368/g" \
chainmaker-v2.3.7-node4/config/node4/chainmaker.yml'
监控开关
# 启用 Prometheus 监控
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec sed -i \
"/monitor:/,+3 {/enabled:/s/false/true/}" {} \;'
网络监听地址
# 将监听地址从 0.0.0.0 改为 127.0.0.1
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec sed -i \
"s/listen_addr: \/ip4\/0.0.0.0/listen_addr: \/ip4\/127.0.0.1/g" {} \;'
日志配置(log.yml)
# 系统日志级别调整
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "log.yml" -exec sed -i \
"/system:/, +6 {/log_level_default:/s/INFO/ERROR/}" {} \;'
# 模块级日志调整
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "log.yml" -exec sed -i \
"/system:/, +6 {/net:/s/net/consensus/}" {} \;'
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "log.yml" -exec sed -i \
"/system:/, +6 {/#vm:/s/#vm: ERROR/txpool: INFO/}" {} \;'
# 验证配置
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "log.yml" -exec grep -A 6 "system:" {} \;'
配置校验命令集
# 检查各项配置是否生效
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec grep -H "slow_log" {} \;' | grep "slow_log" | wc -l
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec grep -H "pool_type" {} \;'
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec grep -H "rolling_window_cache_capacity" {} \;'
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec grep "enable_rwc" {} \;' | grep "enable_rwc" | wc -l
ansible shard_servers -m shell -a \
'cd /data/shard && find chainmaker-v2.3.7-node*/config/node*/ \
-name "chainmaker.yml" -exec grep -A 2 "write_buffer_size" {} \;'
Prometheus 监控部署
# 批量部署 node_exporter(排除特定节点)
ansible all_servers --limit='!<excluded_nodes>' -m shell -a \
'cd /data/ && tar zxvf node_exporter-1.3.1.linux-amd64.tar.gz && \
cd node_exporter-1.3.1.linux-amd64 && nohup ./node_exporter > nohup.out 2>&1 &'
# 验证部署(期望 198,排除 2 台)
ansible all_servers -m shell -a 'pgrep node_exporter' | grep -v rc=0 | wc -l
# 启动 Prometheus
nohup prometheus --config.file=prometheus.yml \
--storage.tsdb.path=/data/prometheus \
--storage.tsdb.retention.time=30d \
--storage.tsdb.retention.size=50GB 2>&1 &
压测工具与数据准备
SFC 压测工具
多压力机并行压测
每台压力机处理不同的数据文件,公式 (n-1)*3+1 计算起始文件编号:
# 压力机 1:3 个并行进程
./sfc benchmark --requests 1048575 --sdk-conf-path ./testdata/config.yml --tx mintdata_1
./sfc benchmark --requests 1048575 --sdk-conf-path ./testdata/config.yml --tx mintdata_2
./sfc benchmark --requests 1048575 --sdk-conf-path ./testdata/config.yml --tx mintdata_3
wait
# 压力机 2:3 个并行进程
./sfc benchmark --requests 1048575 --sdk-conf-path ./testdata/config.yml --tx mintdata_4
./sfc benchmark --requests 1048575 --sdk-conf-path ./testdata/config.yml --tx mintdata_5
./sfc benchmark --requests 1048575 --sdk-conf-path ./testdata/config.yml --tx mintdata_6
CMC 内置并行压测
./cmc parallel invoke \
--loop-num=1000 \
--print-time=3 \
--thread-num=10 \
--timeout=3600 \
--sleep-time=1 \
--climb-time=5 \
--contract-name=fact \
--method=save \
--chain-id=chain1 \
--org-IDs=wx-org1.chainmaker.org \
--hosts=127.0.0.1:12301 \
--tls-host-names=chainmaker.org \
--use-tls=true \
--ca-path=./testdata/crypto-config/wx-org1.chainmaker.org/ca \
--user-keys=./testdata/crypto-config/wx-org1.chainmaker.org/user/client1/client1.tls.key \
--user-crts=./testdata/crypto-config/wx-org1.chainmaker.org/user/client1/client1.tls.crt \
--sign-keys=./testdata/crypto-config/wx-org1.chainmaker.org/user/client1/client1.sign.key \
--sign-crts=./testdata/crypto-config/wx-org1.chainmaker.org/user/client1/client1.sign.crt \
--pairs-file=/data/workspace/prod/contracts-go/fact/params.json
| 参数 | 说明 |
|---|---|
--loop-num | 每线程循环次数 |
--thread-num | 并发线程数 |
--climb-time | 爬坡时间(秒),逐步增加并发 |
--pairs-file | 参数文件路径 |
--sleep-time | 每轮间隔(毫秒) |
user invoke 并发压出块
用 user invoke 自带的并发参数压出块:--concurrency 个 goroutine 各发 --total-count-per-goroutine 笔,总量 = 两者相乘。
./cmc client contract user invoke \
--sdk-conf-path=./testdata/sdk_config.yml \
--contract-name=T \
--method=P \
--params='{"k":"keyname","v":"abcdef0123456789abcdef0123456789"}' \
--concurrency=16 \
--total-count-per-goroutine=1000
| 参数 | 说明 |
|---|---|
--contract-name / --method / --params | 被测合约、方法与入参(示例里是 T 合约的 P 方法,按实际替换) |
--concurrency | 并发 goroutine 数 |
--total-count-per-goroutine | 每个 goroutine 发送的笔数 |
- 上面这条:
16 × 1000 = 16000笔,目的不是验证业务正确性,而是把区块打满——压出块 - 未带
--sync-result,不等回执,适合纯压量;成功率另用链上查询核对 - 压力上不去先查两个上限:
block_tx_capacity(见「CMC · 链配置管理」)与txpool.max_txpool_size(见「性能调优配置汇总」) - 要验的是防重而不是压量:复用真实 txid 串行/并发重放,见「交易防重验证(txfilter)」
xargs 并行压测
适合快速验证少量交易:
seq 1 10000 | xargs -I {} -P 10 -n 1 sh -c '
./cmc client contract user invoke \
--contract-name=fact \
--method=save \
--sdk-conf-path=./testdata/sdk_config.yml \
--params="{\"file_name\":\"name-$1\",\"file_hash\":\"ab3456df5799b87c77e7f88-$1\",\"time\":\"6543234\"}" \
--sync-result=true
' sh {}
交易数据格式
压测工具使用 JSON 格式定义交易模板。random 字段控制是否随机化参数值。
MINT 交易
{
"contract_name": "DPOS_ERC20",
"contract_method": "MINT",
"tx_type": 0,
"params": {
"contract_name": {"data": "DPOS_ERC20", "random": 0},
"shard_num": {"data": "1", "random": 0},
"topic": {"data": "topic", "random": 0},
"to": {"data": "14762327914152", "random": 0},
"value": {"data": "1000000000", "random": 0}
}
}
TRANSFER_FROM 交易
{
"contract_name": "DPOS_ERC20",
"contract_method": "TRANSFER_FROM",
"tx_type": 0,
"params": {
"contract_name": {"data": "DPOS_ERC20", "random": 0},
"shard_num": {"data": "1", "random": 0},
"topic": {"data": "topic", "random": 0},
"from": {"data": "14762327914152", "random": 0},
"to": {"data": "14762327914152", "random": 0},
"value": {"data": "1", "random": 0}
}
}
批量测试数据生成
分片参数配置
不同分片的参数文件只需修改 shard_num 字段:
Go 参数生成工具
用于批量生成存证合约的随机交易参数文件:
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"os"
)
func main() {
inputJSON := `[
{"key":"file_name","value":"001","unique":false,"increase":true},
{"key":"file_hash","value":"ab3456df5799b87c77e7f88","unique":false},
{"key":"time","value":"6543234","unique":false,"increase":true}
]`
var data []map[string]interface{}
if err := json.Unmarshal([]byte(inputJSON), &data); err != nil {
log.Fatal("JSON 解析错误:", err)
}
// 生成 256 字节随机数据并 Base64 编码
randomBytes := make([]byte, 1<<8)
if _, err := rand.Read(randomBytes); err != nil {
log.Fatal("随机数据生成错误:", err)
}
base64Str := base64.StdEncoding.EncodeToString(randomBytes)
// 更新 file_hash 字段
for i, item := range data {
if item["key"] == "file_hash" {
data[i]["value"] = base64Str
break
}
}
outputJSON, err := json.Marshal(data)
if err != nil {
log.Fatal("JSON 序列化错误:", err)
}
if err := os.WriteFile("/data/workspace/prod/contracts-go/fact/params.json", outputJSON, 0644); err != nil {
fmt.Println(err)
}
}
压测数据分片
压测数据需要按账户地址范围分配到不同压力机。每台压力机处理 10 个账户的交易数据,分为 3 个文件。
分片规则
地址前缀按 hex 范围划分,每台压力机负责一段地址区间。公式:(n-1)*3+1 计算起始文件编号。
| 账户范围 | 地址前缀 | 主压力机 | 备用压力机 |
|---|---|---|---|
| 1-10 | 0[1-9a] | stress2 (10.x.x.101) | stress10 (10.x.x.115) |
| 11-20 | 0[b-f], 1[0-4] | stress1 (10.x.x.86) | stress40 (10.x.x.80) |
| 21-30 | 1[5-9a-e] | stress5 (10.x.x.106) | stress14 (10.x.x.119) |
| 31-40 | 1f, 2[0-8] | stress9 (10.x.x.114) | stress36 (10.x.x.76) |
| 41-50 | 2[9a-f], 3[0-2] | stress17 (10.x.x.123) | stress18 (10.x.x.124) |
| 51-60 | 3[3-9a-c] | stress13 (10.x.x.118) | stress22 (10.x.x.129) |
| 61-70 | 3[d-f], 4[0-6] | stress21 (10.x.x.127) | stress26 (10.x.x.50) |
| 71-80 | 4[7-9a-f], 50 | stress25 (10.x.x.131) | stress30 (10.x.x.68) |
| 81-90 | 5[1-9a] | stress29 (10.x.x.67) | stress34 (10.x.x.72) |
| 91-100 | 5[b-f], 6[0-4] | stress33 (10.x.x.71) | stress38 (10.x.x.77) |
| 101-110 | 6[5-9a-e] | stress37 (10.x.x.51) | stress42 (10.x.x.84) |
| 111-120 | 6f, 7[0-8] | stress41 (10.x.x.83) | stress46 (10.x.x.57) |
| 121-130 | 7[9a-f], 8[0-2] | stress45 (10.x.x.55) | stress50 (10.x.x.62) |
| 131-140 | 8[3-9a-c] | stress49 (10.x.x.61) | stress48 (10.x.x.60) |
| 141-150 | 8[d-f], 9[0-6], 00 | stress6 (10.x.x.108) | stress44 (10.x.x.53) |
数据提取示例:
# 设置编码
export PYTHONIOENCODING=utf-8
# 自动生成分片数据
python3 generate_transfer_from.py -b "cross_shard_5pct/addresses_*" -o ./transfer_from_5pct
手动提取示例(以账户 1-10 为例):
# 从 150 个原始文件中按地址前缀提取到 3 个目标文件
ls transfer_from_{1..50} 2>/dev/null \
| xargs -I {} bash -c "grep -E '\"from\":{\"data\":\"0[1-9a]' {}" > transfer_from_4
ls transfer_from_{51..100} 2>/dev/null \
| xargs -I {} bash -c "grep -E '\"from\":{\"data\":\"0[1-9a]' {}" > transfer_from_5
ls transfer_from_{101..150} 2>/dev/null \
| xargs -I {} bash -c "grep -E '\"from\":{\"data\":\"0[1-9a]' {}" > transfer_from_6
集群压测:压力机管理
部署压力工具
ansible stress_servers -m copy -a "src=./helper dest=/data"
ansible stress_servers -m shell -a "cd /data && tar zxvf sfc_stress.tgz"
# 更新压测脚本和二进制
ansible stress_servers -m copy -a \
"src=start_transfer_stress.sh dest=/data/sfc_stress/start_transfer_stress.sh"
ansible stress_servers -m copy -a "src=sfc dest=/data/sfc_stress/sfc"
# 分发压测数据包
ansible stress_servers -m copy -a \
"src=/data/mock_data/transfer_from_5pct.tgz dest=/data/sfc_stress/mock/"
# 解压压测数据包
ansible stress_servers -m shell -a \
'cd /data/sfc_stress/mock && tar zxvf transfer_from_5pct.tgz'
压测参数调整
# 调整持续时间
ansible stress_servers -m shell -a \
'cd /data/sfc_stress && sed -i "s/DURATION=600/DURATION=300/g" start_transfer_stress.sh'
# 调整并发数
ansible stress_servers -m shell -a \
'cd /data/sfc_stress && sed -i "s/CONCURRENCY=150/CONCURRENCY=100/g" start_transfer_stress.sh'
# 调整休眠时间
ansible stress_servers -m shell -a \
'cd /data/sfc_stress && sed -i "s/SLEEP_TIME=5/SLEEP_TIME=1/g" start_transfer_stress.sh'
# 添加忽略未发送分片标志
ansible stress_servers -m shell -a \
'cd /data/sfc_stress && sed -i \
"s/--main-methods/--ignore-not-send-shards true --main-methods/g" start_transfer_stress.sh'
# 验证参数
ansible stress_servers -m shell -a \
"cd /data/sfc_stress && grep 'DURATION=' start_transfer_stress.sh" \
| grep "DURATION=300" | wc -l
ansible stress_servers -m shell -a \
"cd /data/sfc_stress && grep 'CONCURRENCY=' start_transfer_stress.sh" \
| grep "CONCURRENCY=100" | wc -l
SDK 配置调优
# 关闭自动检测优化
ansible stress_servers -m shell -a \
'cd /data/sfc_stress/testdata && find sdk_config_node_* -name "shard*" \
-exec sed -i "s/optimize_detection: 60/optimize_detection: -1/g" {} \;'
# 减少连接数
ansible stress_servers -m shell -a \
'cd /data/sfc_stress/testdata && find sdk_config_node_* -name "shard*" \
-exec sed -i "s/conn_cnt: 10/conn_cnt: 2/g" {} \;'
# 批量替换链名称(shard → chain)
ansible stress_servers -m shell -a \
'cd /data/sfc_stress/testdata && find config_pk_* \
-exec sed -i "s/\"shard/\"chain/g" {} \;'
# 批量替换合约名称(user_save → DPOS_ERC20)
ansible stress_servers -m shell -a \
'cd /data/sfc_stress/testdata && find config_pk_* \
-exec sed -i "s/contract: \"user_save\"/contract: \"DPOS_ERC20\"/g" {} \;'
# 启用指定分片配置(取消注释)
ansible stress_servers -m shell -a \
'cd /data/sfc_stress/testdata && find config_pk_* \
-exec sed -i "s/#- shard_id: \"chain19/- shard_id: \"chain19/g" {} \;'
ansible stress_servers -m shell -a \
'cd /data/sfc_stress/testdata && find config_pk_* \
-exec sed -i "s/#- shard_id: \"chain146/- shard_id: \"chain146/g" {} \;'
ansible stress_servers -m shell -a \
'cd /data/sfc_stress/testdata && find config_pk_* \
-exec sed -i "s|#config_path: \"./testdata/sdk_config_node_4/shard19.yml|config_path: \"./testdata/sdk_config_node_4/shard19.yml|g" {} \;'
ansible stress_servers -m shell -a \
'cd /data/sfc_stress/testdata && find config_pk_* \
-exec sed -i "s|#config_path: \"./testdata/sdk_config_node_4/shard146.yml|config_path: \"./testdata/sdk_config_node_4/shard146.yml|g" {} \;'
执行压测
# 清理旧日志
ansible stress_servers -m shell -a "cd /data/sfc_stress && rm -rf ./results/* log sdk.log*"
# 铸币
ansible-playbook .ansible/playbooks/start_mint.yml
# 转账压测(全部压力机)
ansible-playbook .ansible/playbooks/start_transfer_stress.yml
# 转账压测(指定压力机)
ansible-playbook .ansible/playbooks/start_transfer_stress.yml \
-e "target=stress1,stress5,stress9,stress13,stress17,stress21,stress25,stress29,stress33,stress37,stress41,stress45,stress49,stress2,stress6,stress10,stress40,stress14,stress36,stress18,stress22,stress26,stress30,stress34,stress38,stress42,stress46,stress50,stress48,stress44"
# 单机压测(自定义数据目录和任务数)
ansible-playbook .ansible/playbooks/start_transfer_stress.yml \
-e "target=stress5 transfer_data_dir=/data/sfc_stress/mock/transfer_from_1pct task_count=1"
# 全部压力机(自定义数据目录和任务数)
ansible-playbook .ansible/playbooks/start_transfer_stress.yml \
-e "target=stress_servers transfer_data_dir=/data/sfc_stress/mock/transfer_from_50pct task_count=1 duration=600 concurrency=50"
压测状态检查
# 通过 playbook 检查
ansible-playbook .ansible/playbooks/check_sfc_status.yml
# 指定检查轮次
ansible-playbook .ansible/playbooks/check_sfc_status.yml -e "count=3"
# 手动检查 sfc 进程数
ansible stress_servers -m shell -a "pgrep sfc | wc -l" \
| grep -v "rc=0" | grep "0" | wc -l
# 停止所有 sfc 进程
ansible stress_servers -m shell -a "pkill sfc"
结果收集与分析
# 同步结果到控制节点
ansible stress_servers -m synchronize -a \
"mode=pull src=/data/sfc_stress/results/ dest=./sfc_results/"
# 汇总 TPS
find sfc_results/transfer_stress_stress*.out -exec grep -H "TPS" {} \;
# 计算校准 TPS 平均值
grep "校准TPS" sfc_results/transfer_stress_stress* \
| awk -F':' '{print $NF}' \
| awk '{sum+=$1; count++} END {printf "总平均值: %.2f\n", sum/count}'
交易防重验证(txfilter)
用同一个 T/P 合约验证防重:先发一笔真交易拿到 txid,再用 --tx-id 重放它,期望被 txfilter 拦下。
重放测试:复用真实 txid
# ① 先发一笔,拿到 txid(--sync-result=true 才会返回结果)
./cmc client contract user invoke \
--sdk-conf-path=./testdata/sdk_config.yml \
--contract-name=T --method=P \
--params='{"k":"keyname","v":"abcdef0123456789abcdef0123456789"}' \
--sync-result=true
# → 记下返回的 txId,例如 7c9e98befbb64cec916765d760d4def5aa26f8bac78d419c9018b8d220e7f041
# ② 重放同一 txid(期望:被防重拦截)
./cmc client contract user invoke \
--sdk-conf-path=./testdata/sdk_config.yml \
--contract-name=T --method=P \
--params='{"k":"keyname","v":"abcdef0123456789abcdef0123456789"}' \
--tx-id=7c9e98befbb64cec916765d760d4def5aa26f8bac78d419c9018b8d220e7f041 \
--sync-result=true
# ③ 并发重放同一 txid(--tx-id 下 concurrency 失效,在 shell 层并发)
TXID=7c9e98befbb64cec916765d760d4def5aa26f8bac78d419c9018b8d220e7f041
for i in $(seq 1 32); do
./cmc client contract user invoke --sdk-conf-path=./testdata/sdk_config.yml \
--contract-name=T --method=P \
--params='{"k":"keyname","v":"abcdef0123456789abcdef0123456789"}' \
--tx-id=$TXID --sync-result=true &
done; wait
txid 必须复用 ① 返回的,不要手填
手填字符串不会命中 shardingbirdsnest 过滤器,IsExists 会走 DB 兜底路径(tx_filter_test.go 的 NormalKeyDBError 分支)——那测的是数据库,不是防重组件。只有时间戳格式的真实 txid 才走过滤器主路径。
边界:同 k/v ≠ 双花
把一条并发压量命令 原样跑两遍(量级随意,下面按 80k 计):
第一遍:80,000 笔,txid = t1..t80000,全部写入 k="keyname"
第二遍:80,000 笔,txid = t80001..t160000,参数完全相同
结果:160,000 笔全部被接受 —— 正常,不是 bug
| 场景 | txfilter 行为 | 归属 |
|---|---|---|
| 同一笔交易(同 txid)重复提交 | 拦截 | txfilter 职责 |
| 相同业务参数(同 k/v)但不同 txid | 放行 | 合约层幂等性,不是 txfilter 职责 |
指望「第二遍被拒」,是把 txid 级去重和业务级幂等混为一谈。要做业务幂等(如转账只成功一次),得在合约 P 里加唯一性约束——例如把业务单号写入 KV 并判重,而不是指望 txfilter。
验证与对账
# 确认只入块一次
./cmc query tx <txid> --chain-id=chain1 --sdk-conf-path=./testdata/sdk_config.yml
./cmc query block-by-txid <txid> --chain-id=chain1 --sdk-conf-path=./testdata/sdk_config.yml
# 看重复交易在池里哪个阶段被拦(节点侧)
./cmc txpool status --sdk-conf-path=./testdata/sdk_config.yml
./cmc txpool txs --tx-ids=<txid> --sdk-conf-path=./testdata/sdk_config.yml
节点侧拦截点:交易验证走 filter.IsExists(tx.Payload.TxId, ...)(module/core/common/tx_helper.go:419),提交后 txid 入过滤器(module/core/common/committer.go:154)。
一句话:并发压量命令留着做压测;防重验证用 --tx-id 复用真实 txid 串行/并发重放——两者是完全不同的测试目标。
日志分析
出块信息
# 统计出块数量
grep "common/block_helper.go:1092" chainmaker-v2.3.7-node1/log/system.log | wc -l
# 实时查看出块日志
tail -f -n 10 chainmaker-v2.3.7-node1/log/system.log \
| grep "commit block\|tps\|fetch\|txVerify"
# 查看指定时间段日志
grep "commit block\|tps\|fetch\|txVerify\|put block" \
chainmaker-v2.3.7-node1/log/system.log.2026021111
错误排查
grep "WARN\|ERROR" chainmaker-v2.3.7-node3/log/system.log.2026021111
# 过滤已知无害告警
grep "WARN\|ERROR" chainmaker-v2.3.7-node3/log/system.log.2026021111 \
| grep -v "kvdbhelper.go:504\|blockstore_impl.go:1271\|blockstore_impl.go:1261"
共识流程追踪
# 追踪指定区块的完整共识流程
# 用法:设置 BLOCK_HEIGHT 为目标区块高度
BLOCK_HEIGHT=1202 find chainmaker-v2.3.7-node*/log -name system.log -execdir bash -c \
'grep -H "attempt enterNewRound to ('"$BLOCK_HEIGHT"'/0)\
|('"$BLOCK_HEIGHT"'/0/PROPOSE) receive prevote\
|('"$BLOCK_HEIGHT"'/0/PROPOSE) receive proposal\
|simulate with dag finished, block '"$BLOCK_HEIGHT"'\
|'"$BLOCK_HEIGHT"'/0/PRECOMMIT) consensus save wal\
|commit block \['"$BLOCK_HEIGHT"'\]\
|put block\['"$BLOCK_HEIGHT"'\]" $(pwd)/system.log.2026021111' \;
单机单文件版本(不分节点目录):
grep "attempt enterNewRound to (7787/0)\
\|(7787/0/PROPOSE) receive prevote\
\|(7787/0/PROPOSE) receive proposal\
\|simulate with dag finished, block 7787\
\|7787/0/PRECOMMIT) consensus save wal\
\|commit block \[7787\]" system.log
判断提案节点
grep "tps" chainmaker-v2.3.7-node1/log/system.log.2026021111 | grep "block 1203"
grep "tps" chainmaker-v2.3.7-node2/log/system.log.2026021111 | grep "block 1203"
grep "tps" chainmaker-v2.3.7-node3/log/system.log.2026021111 | grep "block 1203"
grep "tps" chainmaker-v2.3.7-node4/log/system.log.2026021111 | grep "block 1203"
刷盘耗时分析
pprof 性能分析
需要在 chainmaker.yml 中开启 pprof:
抓取与分析:
# 抓取堆内存快照
go tool pprof http://localhost:16060/debug/pprof/heap
# 进入交互式终端后常用命令:
# top 20 — 查看内存占用前 20 的函数
# list <func> — 查看指定函数的逐行内存分配
# web — 在浏览器中打开调用图(需安装 graphviz)
# png > heap.png — 导出调用图为图片
# quit — 退出
# 也可以直接导出 SVG 不进入交互模式
go tool pprof -svg http://localhost:16060/debug/pprof/heap > heap.svg
# 抓取 CPU profile(默认采样 30 秒)
go tool pprof http://localhost:16060/debug/pprof/profile?seconds=30
# 查看 goroutine 堆栈(排查泄漏)
go tool pprof http://localhost:16060/debug/pprof/goroutine
# 远程节点需替换 localhost 为实际 IP
go tool pprof http://10.x.x.86:16060/debug/pprof/heap
SDK 发送频率分析
# 按分钟统计发送量
for i in {00..20}; do
echo -n "11:$i " && grep "11:$i" sdk.log.2026012611 | wc -l
done
# 按秒统计发送量
for i in {00..59}; do
echo -n "11:05:$i " && grep "11:05:$i" sdk.log.2026012611 | wc -l
done
批量收集节点日志
find chainmaker-v2.3.7-node*/log -name "system.log.2026012716" -exec sh -c '
for file; do
node_dir=$(dirname "$file" | cut -d/ -f1)
new_name="${node_dir}.system.log.2026012716"
cp "$file" "./$new_name"
done
' sh {} +
性能调优配置汇总
chainmaker.yml 变更项
| 配置项 | 调优值 | 说明 |
|---|---|---|
txpool.max_txpool_size | 5000000 | 扩大交易池容量 |
storage.enable_rwc | true | 启用读写缓存 |
storage.write_block_type | 1 | 优化写块模式 |
storage.disable_historydb | true | 关闭历史数据库 |
storage.disable_resultdb | true | 关闭结果数据库 |
storage.disable_contract_eventdb | true | 关闭合约事件数据库 |
storage.logdb_segment_async | true | 异步 logdb 分段 |
storage.db_config.leveldb_config.write_buffer_size | 64 | 调整写缓冲区 |
storage.db_config.leveldb_config.no_sync | true | 关闭同步写入 |
bc.yml(链配置)变更项
| 配置项 | 调优值 | 变更命令 |
|---|---|---|
consensus.ext_config.WAL_write_mode | 2 | cmc client chainconfig consensusextra add |
consensus.ext_config.TBFT_blocks_per_proposer | 1 | cmc client chainconfig consensusextra update |
block.block_tx_capacity | 5000 | cmc client chainconfig block update |
辅助工具
数据备份(Helper)
./helper store backup \
-c=../config/wx-org1.chainmaker.org/chainmaker.yml \
--chain-id=chain1 \
--path=../backup/bak-1 \
--start-height=-1 \
--end-height=-1
参数说明
--start-height=-1 --end-height=-1 表示备份全量数据。
单元测试与覆盖率
# 生成覆盖率报告
go test -coverprofile=coverage.out && go tool cover -html=coverage.out -o coverage.html
# 全模块覆盖率
go test -coverprofile=coverage.out ./...
# 查看函数级覆盖率
go tool cover -func=coverage.out
代码行数统计
附录:区块链同步服务流程
graph TD
subgraph 初始化阶段
A[创建 NewBlockChainSyncServer 实例] --> B[加载配置: 网络参数, 同步策略等]
B --> C[初始化核心组件: 区块存储, 本地状态管理器]
end
subgraph 启动服务
C --> D[启动调度器 Scheduler]
D --> E[初始化消息处理器 Handler]
E --> F[注册网络消息监听]
end
subgraph 运行阶段 - 定时任务
F --> G[启动定时器]
G --> H[定时检查任务 1: 节点状态同步]:::timer
G --> I[定时检查任务 2: 区块高度同步]:::timer
H --> J[生成状态同步请求?]:::decision
I --> K[生成区块同步请求?]:::decision
J -- 是 --> L[提交同步请求到调度器队列]
K -- 是 --> L
end
subgraph 运行阶段 - 消息处理
F --> M[监听网络消息]
M -- 接收状态同步响应 --> E
M -- 接收新区块/区块头数据 --> N[处理区块/区块头]
N --> O[更新本地存储]
N --> P[更新本地状态]
O --> Q[通知应用层?]:::decision
P --> Q
Q -- 是 --> R[触发事件通知]
end
classDef timer fill:#f9f,stroke:#333,stroke-width:2px;
classDef decision fill:#ff9,stroke:#333,stroke-width:2px;