在Web3前端用Node.js子进程批量校验钱包,我踩了这些性能与安全的坑

19 阅读6分钟

背景

上个月,我接了一个NFT项目的后台管理工具开发。项目方需要定期向社区贡献者空投NFT,他们有一个包含3000-5000个钱包地址的Excel表格。我的任务是:在前端页面上传这个表格后,快速校验所有地址的有效性,并过滤出无效地址

听起来简单,但实际做起来才发现坑不少。最初我用ethers.jsisAddress函数写了个简单的循环:

// 最初的天真版本
const validateAddresses = (addresses: string[]) => {
  const results = [];
  for (const addr of addresses) {
    results.push({
      address: addr,
      isValid: ethers.isAddress(addr)
    });
  }
  return results;
};

问题来了:当地址数量超过1000个时,页面直接卡死,控制台警告"长任务阻塞主线程"。用户得等上10多秒才能看到结果,体验极差。而且,如果校验过程中用户进行其他操作,整个页面都会卡顿。

问题分析

我首先想到的是Web Worker——浏览器端的多线程方案。我创建了一个Worker文件,把校验逻辑放进去:

// worker.ts
self.onmessage = (e) => {
  const { addresses } = e.data;
  const results = addresses.map(addr => ({
    address: addr,
    isValid: ethers.isAddress(addr)
  }));
  self.postMessage(results);
};

这确实解决了主线程阻塞的问题,但带来了新问题:

  1. 内存泄漏:每次校验都创建新的Worker实例,旧实例没有正确销毁
  2. 性能瓶颈:单个Worker处理5000个地址仍需3-4秒
  3. 依赖问题:Worker中无法直接使用项目中的ethers实例,需要重新初始化

更麻烦的是,我还需要校验地址是否在特定链上存在(通过RPC查询余额),这涉及异步网络请求,在Worker中处理起来更复杂。

这里有个关键发现:我开发的是Electron应用(项目方要求桌面端),这意味着我可以使用Node.js的全部能力,包括child_process多进程。这比Web Worker更强大,每个子进程有独立的内存空间,崩溃不会影响主进程。

核心实现

1. 设计进程通信协议

我决定采用"进程池"模式:创建固定数量的子进程,每个进程处理一批地址。首先需要设计进程间的通信协议:

// types.ts
export interface ValidationTask {
  taskId: string;
  addresses: string[];
  chainId: number;
  rpcUrl: string;
}

export interface ValidationResult {
  taskId: string;
  results: Array<{
    address: string;
    isValid: boolean;
    hasBalance?: boolean;
    error?: string;
  }>;
  processId: number;
}

注意这个细节:每个任务都有唯一的taskId,因为多个任务可能同时进行,需要区分返回结果属于哪个任务。

2. 实现子进程脚本

子进程脚本需要独立运行,我创建了validator-process.ts

// validator-process.ts
import { ethers } from 'ethers';
import type { ValidationTask, ValidationResult } from './types';

// 初始化provider,每个进程独立实例
let provider: ethers.JsonRpcProvider | null = null;

const validateBatch = async (task: ValidationTask): Promise<ValidationResult> => {
  const results = [];
  
  for (const address of task.addresses) {
    try {
      // 基础格式校验
      const isValid = ethers.isAddress(address);
      
      let hasBalance = false;
      // 如果地址格式有效,进一步检查链上余额
      if (isValid && provider) {
        try {
          const balance = await provider.getBalance(address);
          hasBalance = !balance.isZero();
        } catch (error) {
          // RPC调用失败,不影响格式校验结果
          console.error(`RPC查询失败: ${address}`, error);
        }
      }
      
      results.push({
        address,
        isValid,
        hasBalance,
        ...(hasBalance === undefined && { error: 'RPC查询失败' })
      });
    } catch (error) {
      results.push({
        address,
        isValid: false,
        error: error instanceof Error ? error.message : '未知错误'
      });
    }
  }
  
  return {
    taskId: task.taskId,
    results,
    processId: process.pid // 返回进程ID用于监控
  };
};

// 监听父进程消息
process.on('message', async (task: ValidationTask) => {
  try {
    // 延迟初始化provider,避免进程启动时就连接RPC
    if (!provider && task.rpcUrl) {
      provider = new ethers.JsonRpcProvider(task.rpcUrl, task.chainId, {
        staticNetwork: true
      });
    }
    
    const result = await validateBatch(task);
    process.send!(result);
  } catch (error) {
    // 确保错误信息也能返回给父进程
    process.send!({
      taskId: task.taskId,
      results: [],
      processId: process.pid,
      error: error instanceof Error ? error.message : '进程执行错误'
    });
  }
});

// 处理未捕获异常,防止进程静默崩溃
process.on('uncaughtException', (error) => {
  console.error('子进程未捕获异常:', error);
  process.exit(1);
});

这里有个坑:子进程中的console.log输出在Electron中默认看不到,我后来通过ipcRenderer重定向到了渲染进程的console。

3. 创建进程池管理器

在主进程(Node.js端)创建进程池管理器:

// process-pool.ts
import { fork, ChildProcess } from 'child_process';
import path from 'path';
import { EventEmitter } from 'events';

export class ValidatorProcessPool extends EventEmitter {
  private processes: ChildProcess[] = [];
  private taskQueue: Array<{
    task: any;
    resolve: (value: any) => void;
    reject: (reason: any) => void;
  }> = [];
  private busyProcesses = new Set<number>();
  
  constructor(
    private poolSize: number = 4, // 默认4个进程,根据CPU核心数调整
    private scriptPath: string = path.join(__dirname, 'validator-process.js')
  ) {
    super();
    this.initPool();
  }
  
  private initPool() {
    for (let i = 0; i < this.poolSize; i++) {
      const child = fork(this.scriptPath, [], {
        stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
        // 重要:设置进程内存限制,防止单个进程占用过多内存
        execArgv: ['--max-old-space-size=512']
      });
      
      child.on('message', (result) => {
        const pid = child.pid!;
        this.busyProcesses.delete(pid);
        this.emit('taskComplete', { pid, result });
        this.processNextTask();
      });
      
      child.on('exit', (code) => {
        console.warn(`子进程 ${child.pid} 退出,代码: ${code}`);
        // 重启进程
        this.restartProcess(child);
      });
      
      child.on('error', (error) => {
        console.error(`子进程错误:`, error);
      });
      
      this.processes.push(child);
    }
  }
  
  private getAvailableProcess(): ChildProcess | null {
    return this.processes.find(p => !this.busyProcesses.has(p.pid!)) || null;
  }
  
  private processNextTask() {
    if (this.taskQueue.length === 0) return;
    
    const availableProcess = this.getAvailableProcess();
    if (!availableProcess) return;
    
    const { task, resolve, reject } = this.taskQueue.shift()!;
    const pid = availableProcess.pid!;
    this.busyProcesses.add(pid);
    
    // 设置超时,防止任务卡死
    const timeout = setTimeout(() => {
      this.busyProcesses.delete(pid);
      reject(new Error(`任务超时: ${task.taskId}`));
      this.processNextTask();
    }, 30000); // 30秒超时
    
    availableProcess.once('message', (result) => {
      clearTimeout(timeout);
      this.busyProcesses.delete(pid);
      resolve(result);
    });
    
    availableProcess.send(task);
  }
  
  public submitTask(task: any): Promise<any> {
    return new Promise((resolve, reject) => {
      this.taskQueue.push({ task, resolve, reject });
      this.processNextTask();
    });
  }
  
  private restartProcess(oldProcess: ChildProcess) {
    const index = this.processes.indexOf(oldProcess);
    if (index > -1) {
      this.processes.splice(index, 1);
      this.busyProcesses.delete(oldProcess.pid!);
      
      const newProcess = fork(this.scriptPath, [], {
        stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
        execArgv: ['--max-old-space-size=512']
      });
      
      // 复制事件监听器...
      this.processes.push(newProcess);
    }
  }
  
  public async shutdown() {
    // 优雅关闭:先完成队列中的任务
    while (this.taskQueue.length > 0) {
      await new Promise(resolve => setTimeout(resolve, 100));
    }
    
    // 终止所有子进程
    for (const process of this.processes) {
      process.kill('SIGTERM');
    }
  }
}

关键优化:我设置了--max-old-space-size=512限制每个进程最大内存为512MB,防止单个地址列表过大导致内存溢出。

4. 前端集成与进度展示

在React组件中集成进程池,并显示实时进度:

// AddressValidator.tsx
import React, { useState, useRef, useEffect } from 'react';
import { ValidatorProcessPool } from './process-pool';

const AddressValidator: React.FC = () => {
  const [progress, setProgress] = useState(0);
  const [results, setResults] = useState<any[]>([]);
  const [isProcessing, setIsProcessing] = useState(false);
  const processPoolRef = useRef<ValidatorProcessPool | null>(null);
  
  useEffect(() => {
    // 初始化进程池
    processPoolRef.current = new ValidatorProcessPool(
      navigator.hardwareConcurrency || 4
    );
    
    return () => {
      // 组件卸载时清理
      processPoolRef.current?.shutdown();
    };
  }, []);
  
  const validateAddresses = async (addresses: string[]) => {
    if (!processPoolRef.current) return;
    
    setIsProcessing(true);
    setProgress(0);
    setResults([]);
    
    const batchSize = 100; // 每批处理100个地址
    const batches = [];
    
    // 分割成多个批次
    for (let i = 0; i < addresses.length; i += batchSize) {
      batches.push(addresses.slice(i, i + batchSize));
    }
    
    const allResults = [];
    
    // 使用Promise.all并发提交任务,但进程池会控制并发数
    const tasks = batches.map((batch, index) => ({
      taskId: `batch-${index}-${Date.now()}`,
      addresses: batch,
      chainId: 1, // Ethereum主网
      rpcUrl: process.env.REACT_APP_RPC_URL!
    }));
    
    // 监听进度
    let completed = 0;
    const total = tasks.length;
    
    for (const task of tasks) {
      try {
        const result = await processPoolRef.current.submitTask(task);
        allResults.push(...result.results);
        completed++;
        setProgress(Math.round((completed / total) * 100));
      } catch (error) {
        console.error('批次处理失败:', error);
      }
    }
    
    setResults(allResults);
    setIsProcessing(false);
    
    // 统计结果
    const validCount = allResults.filter(r => r.isValid).length;
    const hasBalanceCount = allResults.filter(r => r.hasBalance).length;
    console.log(`校验完成: ${validCount}个有效地址,${hasBalanceCount}个有余额`);
  };
  
  // 渲染组件...
};

5. 错误处理与重试机制

在实际运行中,我发现RPC调用有时会失败,需要重试机制:

// 在子进程脚本中添加重试逻辑
const queryBalanceWithRetry = async (
  provider: ethers.JsonRpcProvider, 
  address: string,
  maxRetries = 3
): Promise<boolean> => {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const balance = await provider.getBalance(address);
      return !balance.isZero();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      // 指数退避重试
      await new Promise(resolve => 
        setTimeout(resolve, 1000 * Math.pow(2, i))
      );
    }
  }
  return false;
};

完整代码

由于完整代码较长,这里提供核心部分的整合版本。实际项目需要安装依赖:ethers@^6.0.0@types/node

项目结构:

src/
  ├── main/           # Electron主进程
  ├── renderer/       # React渲染进程
  │   ├── components/
  │   │   └── AddressValidator.tsx
  │   └── utils/
  │       ├── process-pool.ts
  │       ├── validator-process.ts
  │       └── types.ts
  └── shared/         # 共享类型

关键整合点:在Electron的主进程中暴露进程池API给渲染进程:

// main/ipc-handlers.ts
import { ipcMain } from 'electron';
import { ValidatorProcessPool } from '../renderer/utils/process-pool';

let processPool: ValidatorProcessPool | null = null;

ipcMain.handle('init-validator-pool', (event, poolSize) => {
  if (!processPool) {
    processPool = new ValidatorProcessPool(poolSize);
  }
  return true;
});

ipcMain.handle('validate-addresses', async (event, task) => {
  if (!processPool) {
    throw new Error('进程池未初始化');
  }
  return await processPool.submitTask(task);
});

ipcMain.handle('shutdown-pool', async () => {
  if (processPool) {
    await processPool.shutdown();
    processPool = null;
  }
});

踩坑记录

  1. 坑1:进程间通信丢失

    • 现象:有时子进程返回结果后,父进程收不到消息
    • 原因:Electron中渲染进程不能直接创建子进程,需要通过主进程转发
    • 解决:所有子进程操作放在主进程,通过IPC与渲染进程通信
  2. 坑2:内存泄漏

    • 现象:长时间运行后内存持续增长
    • 原因:子进程中的ethers.js Provider会缓存请求,没有清理
    • 解决:定期重启子进程,并在每个任务完成后手动清除缓存
  3. 坑3:RPC速率限制

    • 现象:批量查询余额时频繁被RPC节点拒绝
    • 原因:多个进程同时请求,超过节点速率限制
    • 解决:在进程池级别添加请求队列,控制整体请求频率
  4. 坑4:进程僵尸

    • 现象:子进程异常退出后变成僵尸进程
    • 原因:没有正确处理SIGTERM信号
    • 解决:在子进程脚本中添加信号处理,确保资源释放

小结

通过这次实战,我深刻理解了在前端(特别是Electron)中使用多进程处理计算密集型任务的完整流程。核心收获是:合理划分任务粒度、设计健壮的进程通信协议、充分考虑错误恢复机制。这个方案将5000个地址的校验时间从15秒缩短到0.8秒,并且页面完全无卡顿。

未来可以继续优化:实现动态进程池(根据负载自动扩容缩容)、添加更详细的内存监控、支持WebSocket实时进度推送。对于纯浏览器环境,可以考虑改用WebAssembly版本的地址校验库来避免子进程的复杂性。