数据迁移数据脱敏阶段性小结


接触数据迁移工作已经有两周时间了,踩过坑趟过路,今天趁上班前的一个多小时总结一下。

【名词】

数据迁移Data Migration:指将from数据库中的某表数据拷贝到to数据库中的某表。

数据脱敏:在数据迁移过程中,将表中敏感字段进行遮挡或加密,脱敏手段有可逆的和不可逆的,不可逆的如MD5摘要算法,可逆的如移位加密、置换加密和替换加密。考虑到数据脱敏由于字段长度的限制最好保持脱敏前后数据长度一致,Java中一些常见DES、AES等变长算法实际是受限的。

下面将就一个实际任务来说明当前数据迁移的做法。

【from库中的源表】

create table emp_from_10(
    id number(12),
    f1 nvarchar2(10),
    f2 nvarchar2(10),
    f3 nvarchar2(10),
    f4 nvarchar2(10),
    f5 nvarchar2(10),
    f6 nvarchar2(10),
    f7 nvarchar2(10),
    f8 nvarchar2(10),
    f9 nvarchar2(10)
);

目的表完全和源表同构:

create table emp_to_10(
    id number(12),
    f1 nvarchar2(10),
    f2 nvarchar2(10),
    f3 nvarchar2(10),
    f4 nvarchar2(10),
    f5 nvarchar2(10),
    f6 nvarchar2(10),
    f7 nvarchar2(10),
    f8 nvarchar2(10),
    f9 nvarchar2(10)
);

【使用程序给源表塞入数据】

package com.hy.lab;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.util.Arrays;

public class BatchInserter3 {
    //-- 以下为连接Oracle数据库的四大参数
    private static final String DRIVER = "oracle.jdbc.driver.OracleDriver";
    private static final String URL = "jdbc:oracle:thin:@127.0.0.1:1521:orcl";
    private static final String USER = "luna";
    private static final String PSWD = "1234";

    public void insert(int from,int to){
        Connection conn = null;
        PreparedStatement pstmt = null;

        try{
            String sql="insert into emp_from_10(id,f1,f2,f3,f4,f5,f6,f7,f8,f9) values(?,?,?,?,?,?,?,?,?,?)";

            Class.forName(DRIVER);
            conn = DriverManager.getConnection(URL, USER, PSWD);
            conn.setAutoCommit(false);
            pstmt = conn.prepareStatement(sql);
            final String str=repeatCharNTimes('*',10);

            for(int i=from;i){
                pstmt.setLong(1,i);
                pstmt.setString(2,str);
                pstmt.setString(3,str);
                pstmt.setString(4,str);
                pstmt.setString(5,str);
                pstmt.setString(6,str);
                pstmt.setString(7,str);
                pstmt.setString(8,str);
                pstmt.setString(9,str);
                pstmt.setString(10,str);

                pstmt.addBatch();
            }

            pstmt.executeBatch();
            conn.commit();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                pstmt.close();
                conn.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 得到字符c重复n次后的字符串
     * @param c
     * @param n
     * @return
     */
    private static String repeatCharNTimes(char c,int n){
        char[] arr= new char[n];
        Arrays.fill(arr,c);
        return String.valueOf(arr);
    }

    private static String ms2DHMS(long startMs, long endMs) {
        String retval = null;
        long secondCount = (endMs - startMs) / 1000;
        String ms = (endMs - startMs) % 1000 + "ms";

        long days = secondCount / (60 * 60 * 24);
        long hours = (secondCount % (60 * 60 * 24)) / (60 * 60);
        long minutes = (secondCount % (60 * 60)) / 60;
        long seconds = secondCount % 60;

        if (days > 0) {
            retval = days + "d" + hours + "h" + minutes + "m" + seconds + "s";
        } else if (hours > 0) {
            retval = hours + "h" + minutes + "m" + seconds + "s";
        } else if (minutes > 0) {
            retval = minutes + "m" + seconds + "s";
        } else if(seconds > 0) {
            retval = seconds + "s";
        }else {
            return ms;
        }

        return retval + ms;
    }

    public static void main(String[] args){
        long startMs=System.currentTimeMillis();

        BatchInserter3 bit=new BatchInserter3();
        for(int i=0;i<10;i++){
            int start=i*1000000;
            int end=(i+1)*1000000;
            bit.insert(start,end);
        }

        long endMs=System.currentTimeMillis();
        System.out.println("Time elapsed:"+ms2DHMS(startMs,endMs));
    }
}

以上程序是我目前掌握的创建千万级表的最快方案,以上程序运行完毕约6分钟。

【迁移任务】

建模上,数据迁移可以分成迁移任务和迁移作业两部分,迁移任务是将from数据库的信息和源表、to数据库的信息和目的表以及迁移字段的匹配信息记录到json文件,迁移作业是按照迁移任务的安排进行数据迁移和脱敏。

最新的迁移任务json定义文件如下:

{
    "from":{
        "db":{
            "driver":"oracle.jdbc.driver.OracleDriver",
            "name":"luna",
            "pswd":"1234",
            "url":"jdbc:oracle:thin:@127.0.0.1:1521:orcl"
        },
        "table":"emp_from_10"
    },
    "mapping":[
        {
            "fromColumn":"id",
            "maskingClass":"none",
            "params":"",
            "toColumn":"id"
        },
        {
            "fromColumn":"f1",
            "maskingClass":"Shadow",
            "params":"3,3",
            "toColumn":"f1"
        },
        {
            "fromColumn":"f2",
            "maskingClass":"Caesar",
            "params":"25",
            "toColumn":"f2"
        },
        {
            "fromColumn":"f3",
            "maskingClass":"none",
            "params":"3,3",
            "toColumn":"f3"
        },
        {
            "fromColumn":"f4",
            "maskingClass":"none",
            "params":"25",
            "toColumn":"f4"
        },
        {
            "fromColumn":"f5",
            "maskingClass":"none",
            "params":"3,3",
            "toColumn":"f5"
        },
        {
            "fromColumn":"f6",
            "maskingClass":"none",
            "params":"25",
            "toColumn":"f6"
        },
        {
            "fromColumn":"f7",
            "maskingClass":"none",
            "params":"3,3",
            "toColumn":"f7"
        },
        {
            "fromColumn":"f8",
            "maskingClass":"none",
            "params":"25",
            "toColumn":"f8"
        },
        {
            "fromColumn":"f9",
            "maskingClass":"none",
            "params":"3,3",
            "toColumn":"f9"
        }
    ],
    "to":{
        "db":{
            "driver":"oracle.jdbc.driver.OracleDriver",
            "name":"luna",
            "pswd":"1234",
            "url":"jdbc:oracle:thin:@127.0.0.1:1521:orcl"
        },
        "table":"emp_to_10"
    }
}

以上文件里,from是from库的信息和表,to节点记录着to库的信息和表,mapping中记录着from表和to表的字段映射信息,该不该脱敏,用哪个类脱敏由maskingClass字段决定,params则记录着脱敏/加密函数需要的参数,因为各个函数的参数大有不同,不同的参数则用逗号隔开。采用json定义文件是受DataX的设定文件启发的,原定方案是用数据库记录迁移设定信息,那涉及的字段就多了,换用json方式后,用一个clob字段记录json即可。

这个文件写成后,再创建一个同构的Java类,使用ObjectMapper就能把json转成对象,往下的脱敏作业使用这个对象就好了。

MigrationTask类:

package com.hy.domain;

import com.hy.domain.task.Field;
import com.hy.domain.task.Node;

/**
 * 数据迁移任务类
 *
 */
public class MigrationTask {
    private Node from;
    private Node to;
    private Field[] mapping;

    public Node getFrom() {
        return from;
    }

    public void setFrom(Node from) {
        this.from = from;
    }

    public Node getTo() {
        return to;
    }

    public void setTo(Node to) {
        this.to = to;
    }

    public Field[] getMapping() {
        return mapping;
    }

    public void setMapping(Field[] mapping) {
        this.mapping = mapping;
    }
}

Node类:

package com.hy.domain.task;

public class Node {
    private String table;
    private DB db;

    public String getTable() {
        return table;
    }

    public void setTable(String table) {
        this.table = table;
    }

    public DB getDb() {
        return db;
    }

    public void setDb(DB db) {
        this.db = db;
    }
}

DB类:

package com.hy.domain.task;

public class DB {
    private String driver;
    private String name;
    private String pswd;
    private String url;

    public String getDriver() {
        return driver;
    }

    public void setDriver(String driver) {
        this.driver = driver;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPswd() {
        return pswd;
    }

    public void setPswd(String pswd) {
        this.pswd = pswd;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }
}

Field类:

package com.hy.domain.task;

public class Field {
    private String fromColumn;
    private String maskingClass;
    private String params;
    private String toColumn;

    public String getFromColumn() {
        return fromColumn;
    }

    public void setFromColumn(String fromColumn) {
        this.fromColumn = fromColumn;
    }

    public String getMaskingClass() {
        return maskingClass;
    }

    public void setMaskingClass(String maskingClass) {
        this.maskingClass = maskingClass;
    }

    public String getParams() {
        return params;
    }

    public void setParams(String params) {
        this.params = params;
    }

    public String getToColumn() {
        return toColumn;
    }

    public void setToColumn(String toColumn) {
        this.toColumn = toColumn;
    }
}

运行起来是这样的:

        // json to object
        String json= TextFileUtil.readFromFile(filename);
        ObjectMapper mapper = new ObjectMapper();
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        MigrationTask task = mapper.readValue(json, MigrationTask.class);

        // migrate
        MigrationJob2 job=new MigrationJob2newImpl();
        job.setTask(task);
        job.run();

【迁移作业】

迁移作业分接口和实现类两部分:

接口:

package com.hy.domain;

public abstract class MigrationJob2 {
    // 任务
    protected MigrationTask task;

    public MigrationTask getTask() {
        return task;
    }

    public void setTask(MigrationTask task) {
        this.task = task;
    }

    public abstract boolean run();

    // 进行数据脱敏
    protected abstract String getMasking(String field,String value);
}

实现类:

package com.hy.domain;

import com.hy.domain.task.DB;
import com.hy.domain.task.Field;

import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.sql.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class MigrationJob2newImpl extends MigrationJob2{
    private final int BATCH_SIZE=100000;

    @Override
    public boolean run() {
        // get from info
        String table=task.getFrom().getTable();
        DB from=task.getFrom().getDb();

        List columns=new ArrayList<>();
        for(Field field:task.getMapping()){
            columns.add(field.getFromColumn());
        }
        String fieldLine=String.join(",",columns);

        //
        Connection conn = null;
        Statement stmt = null;
        List> lines=new ArrayList<>();

        try{
            Class.forName(from.getDriver());
            conn = DriverManager.getConnection(from.getUrl(), from.getName(), from.getPswd());

            String sql=String.format("select %s from %s order by 1",fieldLine,table);

            stmt = conn.createStatement();
            ResultSet rs = stmt.executeQuery(sql);

            int count=0;

            while (rs.next()) {
                List ls=new ArrayList<>();

                for(String field:columns){
                    String value = rs.getString(field);

                    value=getMasking(field,value);

                    ls.add(value);
                }

                lines.add(ls);

                count++;
                if(count>BATCH_SIZE){
                    count=0;
                    send2To(lines);
                    lines.clear();;
                }
            }
            rs.close();

            send2To(lines);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                stmt.close();
                conn.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }

        return true;
    }

    private void send2To(List> lines){
        String tablename=task.getTo().getTable();

        DB toDb=task.getTo().getDb();
        List columns=new ArrayList<>();
        for(Field field:task.getMapping()){
            columns.add(field.getToColumn());
        }

        Connection conn = null;
        PreparedStatement pstmt = null;
        try{
            Class.forName(toDb.getDriver());
            conn = DriverManager.getConnection(toDb.getUrl(), toDb.getName(), toDb.getPswd());
            conn.setAutoCommit(false);

            // build ?,?,?,...?
            int columnCnt=columns.size();
            String[] arr=new String[columnCnt];
            for(int i=0;i){
                arr[i]="?";
            }

            String sql=String.format("insert into %s(%s) values(%s)",tablename,String.join(",",columns),String.join(",",arr));
            pstmt = conn.prepareStatement(sql);

            for (List ls:lines) {
                for(int i=0;i){
                    pstmt.setString(i+1,ls.get(i));
                }
                pstmt.addBatch();
            }

            pstmt.executeBatch();
            conn.commit();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                pstmt.close();
                conn.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    protected String getMasking(String column,String value){
        Field currentField=null;

        for(Field field:task.getMapping()){
            if(field.getFromColumn().equalsIgnoreCase(column)){
                currentField=field;
                break;
            }
        }

        if(currentField!=null){
            if(currentField.getMaskingClass().equalsIgnoreCase("none")){
                return value;
            }else{
                try{
                    Class clz = Class.forName("com.hy.masking."+currentField.getMaskingClass());
                    Constructor constructor=clz.getConstructor();
                    Object instance=constructor.newInstance();
                    Method method = clz.getMethod("encrypt", new Class[]{String.class,String[].class});
                    String paramsStr=currentField.getParams();
                    String[] params=paramsStr.split(",");

                    Object result = method.invoke(instance, new Object[]{value,params});

                    return result.toString();
                }catch(Exception ex){
                    return "ERROR";
                }
            }
        }else{
            return "Error";
        }
    }

}

以上函数分三部分:从from库分批读取十万数据,脱敏和写入to库。

分批的目的是服务器和源库处理能力不是无限的;

脱敏时主要用了反射技术,使得定义的文本类名能找到具体类的encrypt函数;

下面是凯撒加密的类代码,大家结合反射部分就能清楚json中定义到具体加密函数的途径:

package com.hy.masking;

public class Caesar {
    public String encrypt(String str,String[] params){
        int shift=Integer.parseInt(params[0]);
        char[] arr=str.toCharArray();

        for(int i=0;i){
            arr[i]+=shift;
        }

        return String.valueOf(arr);
    }

    public String decrypt(String str,String[] params){
        int shift=Integer.parseInt(params[0]);
        char[] arr=str.toCharArray();

        for(int i=0;i){
            arr[i]-=shift;
        }

        return String.valueOf(arr);
    }
}

encrypt是加密用的,decrypt就用于反向解密,要对哪些字段解密,解密方式如何还是用的上面json文件。

写入to库和一开始的插千万数据进表一样,使用了PreparedStatement的batchExecute技术,它的速度比Oracle的批量插入语法还要快,吞吐量也更大,比较适合大数据量传输。这也是我目前所知的最快的数据迁移方案。

 【处理速度】

下面是目前的处理速度:

实验次数 列数 记录数 脱敏列数 阈值BATCH_SIZE 耗时 备注
1 4 一千万 2 10万 5分  
2 4 一千万 2 50万 5分  
3 10 一千万 9 10万 18分  
4 10 一千万 2 10万 15分  
5 10 一千万 2 10万 15分 确认性实验

目前就是这个速度,和dump方案比还是有差距的,比DataX方案效率又要高些。

【代码下载】

https://files.cnblogs.com/files/heyang78/dataMigration220317am.rar?t=1647486264

END

相关