数据库迁移(升级)记录

前言

在 PHA-X 项目早期开发阶段,由于对业务模型和数据库设计经验不足,HAZOP 分析模块采用了不合理的数据库结构设计方案。

1.旧版数据库结构

使用 hazop_projects 表记录所有项目信息;
hazop_{project_id} 表存储单个项目下的节点列表;
再通过 hazop_{project_id}_{node_id} 表记录每个节点对应的分析数据。

该设计在初期具有一定直观性,在可视化界面中层级关系清晰、易于理解。然而,随着项目数量的持续增长,该方案的结构性问题逐渐暴露:

  • 每新增一个节点都会生成新的数据表;
  • 在数十个项目后,数据库中已产生数百张业务表;
  • 数据库结构高度碎片化,表命名复杂,维护成本显著上升;
  • 数据迁移、版本升级、权限控制及通用查询变得困难。

图1 早期数据库结构

尽管 SQLite 在中小规模数据量下对“多表”场景的性能影响并不十分显著(测试文章),但从可维护性、可扩展性角度来看,该结构无法作为持续迭代的基础架构。

因此,需要对 HAZOP 模块进行重构,对数据库模型进行重新设计,以减少表数量、统一数据结构、提升整体可读性和工程可持续性,还技术债务。

2.新版数据库结构

重构目标:

  • 减少表数量,避免随项目和节点数量导致表数量膨胀
  • 统一模型,消除动态表名带来的维护与查询复杂度
  • 明确数据表边界,清晰划分“项目—节点—分析记录”的层级关系

新的设计采用固定表结构 + 逻辑关联的方式,以关系建模替代“一项目一表 / 一节点一表”的物理拆分策略,最终数据库结构如下:

图2 改进后的数据库表结构

3. 新结构查询性能

在统一表结构(hazop_projects + hazop_nodes + hazop_tables)下:

  • 单节点分析数据查询耗时稳定控制在 3 ms 以内
  • 查询性能与项目数量、节点数量解耦
  • 未观察到随数据规模增长而出现的明显性能退化

该结果表明,基于 固定表结构 + 索引过滤(project_id / node_id) 的查询方式在 SQLite 环境下具有良好的稳定性和可预测性。


4. 旧结构查询性能对比

在旧版“动态表结构”方案下,查询性能随表数量明显下降:

数据规模单节点查询耗时
约 300 张分析表~150 ms
1000+ 张分析表~300 ms

5.结构及逻辑重构

本次重构中最复杂、工作量最大的部分在于所有 API 接口逻辑的系统性重构

在旧架构下,依赖以下特性:

  • 动态表名拼接hazop_{projectId}hazop_{projectId}_{nodeName}
  • 项目、节点、分析数据之间的处理逻辑

几乎所有接口都需要重新设计数据访问路径、事务处理及权限判断逻辑。

DELETE /api/table/project/{projectId}
删除 HAZOP 项目(同时执行数据备份逻辑)

GET /api/table/project/{projectId}
获取项目基本信息

PUT /api/table/project/{projectId}
更新项目信息

POST /api/table/project/{projectId}/lock
锁定项目

POST /api/table/project/{projectId}/unlock
解锁项目

GET /api/table/project/{projectId}/nodes
查询项目下的所有节点

POST /api/table/project/{projectId}/nodes
新建 HAZOP 节点

DELETE /api/table/project/{projectId}/node/{nodeName}
删除节点

PUT /api/table/project/{projectId}/nodes/reorder
节点重新排序

GET /api/table/project/{projectId}/node/{nodeName}/info
获取节点详细信息

PUT /api/table/project/{projectId}/node/{nodeName}/info
更新节点信息

GET /api/table/project/{projectId}/data
查询项目全部 HAZOP 分析数据

GET /api/table/project/{projectId}/node/{nodeName}
查询指定节点的分析数据

GET /api/table/project/{projectId}/impact
获取项目影响信息

GET /api/table/project/{projectId}/suggestions
获取项目建议措施数据

DELETE /api/table/project/:projectId/suggestions/:suggestionId
删除单个建议措施

GET /api/table/project/:projectId/summary
获取项目风险汇总数据

GET /api/table/project-nodes/:projectId
获取节点列表

GET /project/:projectId/node/:nodeName/text-styles
获取单元格文字样式

GET /project/:projectId/high-risk-scenarios
获取高风险场景

GET /project/:projectId/check-analysis-tables
检查分析表

POST /project/:projectId/node/:nodeName/save
保存节点数据

POST /project/:projectId/node/:nodeName/text-styles
保存文字样式

POST /project/:projectId/replace
项目替换

PUT /project/:projectId/suggestions
更新建议措施

6. 数据迁移

节点表迁移代码

const sqlite3 = require('sqlite3').verbose();
const path = require('path');

const dbPath = path.join(__dirname, 'hazop.db');
const db = new sqlite3.Database(dbPath);


db.serialize(() => {
  const createNodesTableSQL = `
    CREATE TABLE IF NOT EXISTS hazop_nodes (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      project_id INTEGER NOT NULL,
      name TEXT NOT NULL,
      zysb TEXT,
      description TEXT,
      node_sequence TEXT,
      design_intent TEXT,
      medium_hazard_params TEXT,
      pid TEXT,
      data_table TEXT,
      merge_cells TEXT,
      text_styles TEXT,
      sort_order INTEGER DEFAULT 0,
      created_at TEXT DEFAULT (datetime('now')),
      updated_at TEXT DEFAULT (datetime('now')),
      FOREIGN KEY (project_id) REFERENCES hazop_projects(id) ON DELETE CASCADE
    )
  `;
  
  db.run(createNodesTableSQL, (err) => {
  });

  db.run('CREATE INDEX IF NOT EXISTS idx_hazop_nodes_project_id ON hazop_nodes(project_id)', (err) => {
    if (err) {
      console.error('创建索引失败:', err);

  });

  db.run('CREATE INDEX IF NOT EXISTS idx_hazop_nodes_name ON hazop_nodes(project_id, name)', (err) => {
    if (err) {
      console.error('创建索引失败:', err);
    } else {
    }
  });

  db.all('SELECT id, node_table FROM hazop_projects WHERE node_table IS NOT NULL', [], (err, projects) => {

    console.log(` ${projects.length} 个项目需要迁移`);
    
    let migratedCount = 0;
    let totalNodes = 0;

    projects.forEach((project, index) => {
      const projectId = project.id;
      const nodeTable = project.node_table;

      db.get(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, [nodeTable], (err, tableExists) => {

        db.all(`SELECT * FROM ${nodeTable}`, [], (err, nodes) => {
          if (err) {
            console.error(`  项目 ${projectId}: 读取节点失败:`, err);
            migratedCount++;
            if (migratedCount === projects.length) {
              finishMigration();
            }
            return;
          }

          const insertSQL = `
            INSERT INTO hazop_nodes (
              project_id, name, zysb, description, node_sequence, 
              design_intent, medium_hazard_params, pid, data_table, 
              merge_cells, text_styles, sort_order, created_at, updated_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
          `;

          let insertedNodes = 0;
          nodes.forEach(node => {
            db.run(insertSQL, [
              projectId,
              node.name,
              node.zysb,
              node.description,
              node.node_sequence,
              node.design_intent,
              node.medium_hazard_params,
              node.pid,
              node.data_table,
              node.merge_cells,
              node.text_styles,
              node.sort_order || 0,
              node.created_at || null,
              node.updated_at || null
            ], (err) => {
              insertedNodes++;
              if (insertedNodes === nodes.length) {
                totalNodes += nodes.length;
                
                migratedCount++;
                if (migratedCount === projects.length) {
                  finishMigration();
                }
              }
            });
          });
        });
      });
    });

    function finishMigration() {
      db.close();
    }
  });
});

分析表迁移代码

const sqlite3 = require('sqlite3').verbose();
const path = require('path');

const dbPath = path.join(__dirname, 'hazop.db');
const db = new sqlite3.Database(dbPath);


db.serialize(() => {
  const createTablesSQL = `
    CREATE TABLE IF NOT EXISTS hazop_tables (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      project_id INTEGER NOT NULL,
      node_id INTEGER NOT NULL,
      row_order INTEGER,
      
      number TEXT,
      parameter TEXT,
      deviation TEXT,
      cause TEXT,
      category TEXT,
      consequence_description TEXT,
      
      risk_L INTEGER,
      risk_S INTEGER,
      risk_R TEXT,
      
      existing_measures TEXT,
      
      residual_risk1_AL INTEGER,
      residual_risk1_AS INTEGER,
      residual_risk1_AR TEXT,
      
      recommendation TEXT,
      recommendation_reason TEXT,
      recommendation_category TEXT,
      
      residual_risk2_AL INTEGER,
      residual_risk2_AS INTEGER,
      residual_risk2_AR TEXT,
      
      responsible_party TEXT,
      
      created_at TEXT DEFAULT (datetime('now')),
      updated_at TEXT DEFAULT (datetime('now')),
      
      FOREIGN KEY (project_id) REFERENCES hazop_projects(id) ON DELETE CASCADE,
      FOREIGN KEY (node_id) REFERENCES hazop_nodes(id) ON DELETE CASCADE
    )
  `;
  
  db.run(createTablesSQL, (err) => {
  });

  db.run('CREATE INDEX IF NOT EXISTS idx_hazop_tables_project_node ON hazop_tables(project_id, node_id)', (err) => {
    if (err) {
      console.error('创建索引失败:', err);
    } else {
    }
  });

  db.run('CREATE INDEX IF NOT EXISTS idx_hazop_tables_row_order ON hazop_tables(project_id, node_id, row_order)', (err) => {
    if (err) {
      console.error('创建索引失败:', err);
  }
  });

  db.all('SELECT id, project_id, data_table FROM hazop_nodes WHERE data_table IS NOT NULL AND data_table != ""', [], (err, nodes) => {

    console.log(` ${nodes.length} 个节点需要迁移分析数据`);
    
    let migratedCount = 0;
    let totalRows = 0;

    nodes.forEach((node) => {
      const nodeId = node.id;
      const projectId = node.project_id;
      const dataTable = node.data_table;

      db.get(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, [dataTable], (err, tableExists) => {

        db.all(`SELECT * FROM ${dataTable} ORDER BY row_order ASC`, [], (err, rows) => {
          if (err) {
            console.error(`  节点 ${nodeId}: 读取分析数据失败:`, err);
            migratedCount++;
            if (migratedCount === nodes.length) {
              finishMigration();
            }
            return;
          }

          const insertSQL = `
            INSERT INTO hazop_tables (
              project_id, node_id, row_order,
              number, parameter, deviation, cause, category, consequence_description,
              risk_L, risk_S, risk_R,
              existing_measures,
              residual_risk1_AL, residual_risk1_AS, residual_risk1_AR,
              recommendation, recommendation_reason, recommendation_category,
              residual_risk2_AL, residual_risk2_AS, residual_risk2_AR,
              responsible_party,
              created_at, updated_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
          `;

          let insertedRows = 0;
          rows.forEach(row => {
            db.run(insertSQL, [
              projectId,
              nodeId,
              row.row_order,
              row.number,
              row.parameter,
              row.deviation,
              row.cause,
              row.category,
              row.consequence_description,
              row.risk_L,
              row.risk_S,
              row.risk_R,
              row.existing_measures,
              row.residual_risk1_AL,
              row.residual_risk1_AS,
              row.residual_risk1_AR,
              row.recommendation,
              row.recommendation_reason,
              row.recommendation_category,
              row.residual_risk2_AL,
              row.residual_risk2_AS,
              row.residual_risk2_AR,
              row.responsible_party,
              row.created_at || null,
              row.updated_at || null
            ], (err) => {
              if (err) {
                console.error(`    行 ${row.id} 迁移失败:`, err);
              }
              
              insertedRows++;
            });
          });
        });
      });
    });

    function finishMigration() {
    
      db.close();
    }
  });
});

留下评论

您的邮箱地址不会被公开。 必填项已用 * 标注