├── .DS_Store ├── lib ├── .DS_Store └── pull-push.js ├── package.json ├── test ├── mapping.json └── mysql_2_es.js ├── index.js └── readme.md /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parksben/mysql_2_elasticsearch/HEAD/.DS_Store -------------------------------------------------------------------------------- /lib/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parksben/mysql_2_elasticsearch/HEAD/lib/.DS_Store -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mysql_2_elasticsearch", 3 | "version": "1.0.9", 4 | "description": "Customizable importer from mysql to elasticsearch.", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/parksben/mysql_2_elasticsearch.git" 15 | }, 16 | "keywords": [ 17 | "elasticsearch", 18 | "mysql", 19 | "importer", 20 | "customizable", 21 | "river", 22 | "jdbc" 23 | ], 24 | "author": "parksben", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/parksben/mysql_2_elasticsearch/issues" 28 | }, 29 | "homepage": "https://github.com/parksben/mysql_2_elasticsearch#readme", 30 | "devDependencies": { 31 | "elasticsearch": "^12.1.1", 32 | "mysql": "^2.12.0" 33 | } 34 | } -------------------------------------------------------------------------------- /test/mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "analysis": { 4 | "analyzer": { 5 | "ik": { 6 | "tokenizer": "ik_smart" 7 | } 8 | } 9 | } 10 | }, 11 | "mappings": { 12 | "users": { 13 | "_all": { 14 | "analyzer": "ik_smart", 15 | "search_analyzer": "ik_smart", 16 | "term_vector": "no", 17 | "store": "false" 18 | }, 19 | "properties": { 20 | "users_id": { 21 | "type": "long" 22 | }, 23 | "name": { 24 | "type": "text", 25 | "analyzer": "ik_smart", 26 | "search_analyzer": "ik_smart", 27 | "include_in_all": "true", 28 | "boost": 8 29 | }, 30 | "age": { 31 | "type": "number" 32 | }, 33 | "description": { 34 | "type": "text", 35 | "analyzer": "ik_smart", 36 | "search_analyzer": "ik_smart", 37 | "include_in_all": "true", 38 | "boost": 8 39 | }, 40 | "address": { 41 | "type": "text", 42 | "analyzer": "ik_smart", 43 | "search_analyzer": "ik_smart", 44 | "include_in_all": "true", 45 | "boost": 8 46 | } 47 | } 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /test/mysql_2_es.js: -------------------------------------------------------------------------------- 1 | var esMysqlRiver = require('mysql_2_elasticsearch'); 2 | 3 | /* 4 | ** 以下为 mysql_2_elasticsearch 的相关参数配置(详情见注释) 5 | */ 6 | 7 | var river_config = { 8 | 9 | /* [必需] MySQL数据库的相关参数(根据实际情况进行修改) */ 10 | mysql: { 11 | host: '127.0.0.1', 12 | user: 'root', 13 | password: 'root', 14 | database: 'users', 15 | port: 3306 16 | }, 17 | 18 | /* [必需] es 相关参数(根据实际情况进行修改) */ 19 | elasticsearch: { 20 | 21 | host_config: { // [必需] host_config 即 es客户端的配置参数,详细配置参考 es官方文档[https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/configuration.html] 22 | host: 'localhost:9200', 23 | log: 'trace', 24 | // Other options... 25 | }, 26 | 27 | index: 'myIndex', // [必需] es 索引名 28 | chunkSize: 8000, // [非必需] 单个数据分片的最大数据量,不设置则默认为 5000 (条数据) 29 | timeout: '2m' // [非必需] 单次分片请求的超时时间,不设置则默认为 1m (注:此参数并非es客户端请求的timeout,后者请在 host_config 中设置) 30 | 31 | }, 32 | 33 | /* [必需] 数据传送的规则 */ 34 | riverMap: { 35 | 'users => users': { // [必需] 'a => b' 表示将 mysql数据库中名为 'a' 的 table 的所有数据 输送到 es中名为 'b' 的 type 中去 36 | filter_out: [ // [非必需] 需要过滤的字段名,即 filter_out 中的设置的所有字段将不会被导入 elasticsearch 的数据中 37 | 'password', 38 | 'age' 39 | ], 40 | exception_handler: { // [非必需] 异常处理器,使用JS正则表达式处理异常数据,以避免 es 入库时由于数据类型不合法造成的数据丢失,可根据具体需求进行设置 41 | 'birthday': [ // [示例] 对 users 表的 birthday 字段的异常数据进行处理 42 | { 43 | match: /NaN/gi, // [示例] 正则条件(此例匹配字段值为 "NaN" 的情况) 44 | writeAs: null // [示例] 将 "NaN" 重写为 null 45 | }, 46 | { 47 | match: /(\d{4})年/gi, // [示例] 正则表达式(此例匹配字段值为形如 "2016年" 的情况) 48 | writeAs: '$1.1' // [示例] 将 "2015年" 样式的数据重写为 "2016.1" 样式的数据 49 | } 50 | ] 51 | } 52 | }, 53 | // Other fields' options... 54 | } 55 | 56 | }; 57 | 58 | 59 | /* 60 | ** 以下代码内容: 61 | ** 通过 esMysqlRiver 方法进行数据传输,方法的回调参数(一个JSON对象) obj 包含此次数据传输的结果 62 | ** 其中: 63 | ** 1. obj.total => 需要传输的数据表数量 64 | ** 2. obj.success => 传输成功的数据表数量 65 | ** 3. obj.failed => 传输失败的数据表数量 66 | ** 4. obj.failed => 本次数据传输的结论 67 | */ 68 | 69 | esMysqlRiver(river_config, function(obj) { 70 | /* 将传输结果打印到终端 */ 71 | console.log('\n---------------------------------'); 72 | console.log('总传送:' + obj.total + '项'); 73 | console.log('成功:' + obj.success + '项'); 74 | console.log('失败:' + obj.failed + '项'); 75 | if (obj.result == 'success') { 76 | console.log('\n结论:全部数据传送完成!'); 77 | } else { 78 | console.log('\n结论:传送未成功...'); 79 | } 80 | console.log('---------------------------------'); 81 | console.log('\n(使用 Ctrl + C 退出进程)'); 82 | /* 将传输结果打印到终端 */ 83 | }); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var elasticsearch = require('elasticsearch'); 2 | var mysql = require('mysql'); 3 | 4 | var es_jdbc = require('./lib/pull-push.js'); 5 | var pullDataFromMysql = es_jdbc.pull; 6 | var pushDataToElastic = es_jdbc.push; 7 | 8 | var transSingleTable = function(pool, client, config, $table, sqlPhrase, $type, filter_map, exception_handler, callback) { 9 | // 配置项 10 | var es_config = { 11 | host: config.elasticsearch.host_config, 12 | chunkSize: config.elasticsearch.chunkSize, 13 | timeout: config.elasticsearch.timeout, 14 | src_table: $table, 15 | index: config.elasticsearch.index, 16 | type: $type 17 | }; 18 | 19 | // 导出 mysql 数据表 到 bulk文件 20 | pullDataFromMysql(pool, $table, sqlPhrase, filter_map, exception_handler, function(obj) { 21 | if (obj.message == 'success') { 22 | console.log('==>> ' + obj.table + '.bulk.json 文件构造完毕!'); 23 | } 24 | 25 | // 导入 bulk 数据 到 es 26 | pushDataToElastic(client, es_config, function(obj) { 27 | if (obj.message == 'success') { 28 | console.log('====>> /' + obj.index + '/' + obj.type + ' 导入数据成功!'); 29 | callback(true); 30 | } else { 31 | console.log('====>> /' + obj.index + '/' + obj.type + ' 导入数据失败!'); 32 | callback(false); 33 | } 34 | }); 35 | }); 36 | }; 37 | 38 | module.exports = function(config, callback) { 39 | // 开始建立 MySQL 数据库连接 40 | var pool = mysql.createPool( config.mysql ); 41 | console.log('开始连接数据库:' + config.mysql.host + ':' + config.mysql.port + '/' + config.mysql.database); 42 | 43 | // 开始建立 elasticsearch 客户端服务 44 | var client = new elasticsearch.Client(config.elasticsearch.host_config); 45 | console.log('开始建立 elasticsearch 客户端连接...'); 46 | 47 | var mapLen = JSON.stringify(config.riverMap).match(/\=\>/g).length; 48 | var successArr = []; 49 | var failedArr = []; 50 | 51 | for (var key in config.riverMap) { 52 | var curTable = key.split('=>')[0].replace(/\s+/g, ''); 53 | var curType = key.split('=>')[1].replace(/\s+/g, ''); 54 | 55 | // filter_out 56 | if (!config.riverMap[key].filter_out || config.riverMap[key].filter_out.length == 0) { 57 | var filter_map = []; 58 | } else { 59 | var filter_map = config.riverMap[key].filter_out; 60 | } 61 | 62 | // exception_handler 63 | if (!config.riverMap[key].exception_handler) { 64 | var exception_handler = {}; 65 | } else { 66 | var exception_handler = config.riverMap[key].exception_handler; 67 | } 68 | 69 | // 表名为SQL,则表示将SQL查询结果存入对应type 70 | var sqlPhrase = !config.riverMap[key].SQL ? '' : config.riverMap[key].SQL; 71 | 72 | transSingleTable(pool, client, config, curTable, sqlPhrase, curType, filter_map, exception_handler, function(state) { 73 | if (state) { 74 | successArr.push(curTable); 75 | } else { 76 | failedArr.push(curTable); 77 | } 78 | 79 | if (successArr.length + failedArr.length == mapLen) { 80 | callback({ 81 | total: mapLen, 82 | success: successArr.length, 83 | failed: failedArr.length, 84 | result: successArr.length == mapLen ? 'success' : 'failed' 85 | }); 86 | 87 | pool.end(function (err) { 88 | if (err) { 89 | console.log(err); 90 | } 91 | 92 | console.log('Mysql 连接已断开...'); 93 | }); 94 | } 95 | }); 96 | } 97 | }; -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # mysql_2_elasticsearch 2 | 3 | 4 | 可定制的 elasticsearch 数据导入工具 5 | 6 | ##版本更新说明: 7 | 8 | |releases|notes| 9 | |--------|----| 10 | |v1.0.9|对不规范的时间日期字符串的宽容性优化| 11 | |v1.0.7|代码稳健性优化;导库任务结束后自动断开 Mysql 连接| 12 | |v1.0.6|新增功能:支持使用 SQL 语句,将查询结果集导入 ES| 13 | |v1.0.5|新增功能:配置项 ```exception_handler[field_name].writeAs``` 支持传递回调函数| 14 | 15 | ##主要功能 16 | 1. 完全使用 JS 实现数据从 MySQL 到 elasticsearch 的迁移; 17 | 2. 可一次性导入多张 MySQL 数据表和数据集合; 18 | 2. 可自定义的数据迁移的规则(数据表/字段关系、字段过滤、使用正则进行异常处理); 19 | 3. 可自定义的异步分片导入方式,数据导入效率更高。 20 | 21 | ##一键安装 22 | ``` 23 | npm install mysql_2_elasticsearch 24 | ``` 25 | 26 | ##快速开始(简单用例) 27 | ``` 28 | var esMysqlRiver = require('mysql_2_elasticsearch'); 29 | 30 | var river_config = { 31 | mysql: { 32 | host: '127.0.0.1', 33 | user: 'root', 34 | password: 'root', 35 | database: 'users', 36 | port: 3306 37 | }, 38 | elasticsearch: { 39 | host_config: { // es客户端的配置参数 40 | host: 'localhost:9200', 41 | // log: 'trace' 42 | }, 43 | index: 'myIndex' 44 | }, 45 | riverMap: { 46 | 'users => users': {} // 将数据表 users 导入到 es 类型: /myIndex/users 47 | } 48 | }; 49 | 50 | 51 | /* 52 | ** 以下代码内容: 53 | ** 通过 esMysqlRiver 方法进行数据传输,方法的回调参数(一个JSON对象) obj 包含此次数据传输的结果 54 | ** 其中: 55 | ** 1. obj.total => 需要传输的数据表数量 56 | ** 2. obj.success => 传输成功的数据表数量 57 | ** 3. obj.failed => 传输失败的数据表数量 58 | ** 4. obj.result => 本次数据传输的结论 59 | */ 60 | 61 | esMysqlRiver(river_config, function(obj) { 62 | /* 将传输结果打印到终端 */ 63 | console.log('\n---------------------------------'); 64 | console.log('总传送:' + obj.total + '项'); 65 | console.log('成功:' + obj.success + '项'); 66 | console.log('失败:' + obj.failed + '项'); 67 | if (obj.result == 'success') { 68 | console.log('\n结论:全部数据传送完成!'); 69 | } else { 70 | console.log('\n结论:传送未成功...'); 71 | } 72 | console.log('---------------------------------'); 73 | /* 将传输结果打印到终端 */ 74 | }); 75 | ``` 76 | 77 | ##最佳实现(完整用例) 78 | ``` 79 | var esMysqlRiver = require('mysql_2_elasticsearch'); 80 | 81 | /* 82 | ** mysql_2_elasticsearch 的相关参数配置(详情见注释) 83 | */ 84 | 85 | var river_config = { 86 | 87 | /* [必需] MySQL数据库的相关参数(根据实际情况进行修改) */ 88 | mysql: { 89 | host: '127.0.0.1', 90 | user: 'root', 91 | password: 'root', 92 | database: 'users', 93 | port: 3306 94 | }, 95 | 96 | /* [必需] es 相关参数(根据实际情况进行修改) */ 97 | elasticsearch: { 98 | host_config: { // [必需] host_config 即 es客户端的配置参数,详细配置参考 es官方文档 99 | host: 'localhost:9200', 100 | log: 'trace', 101 | // Other options... 102 | }, 103 | index: 'myIndex', // [必需] es 索引名 104 | chunkSize: 8000, // [非必需] 单分片最大数据量,默认为 5000 (条数据) 105 | timeout: '2m' // [非必需] 单次分片请求的超时时间,默认为 1m 106 | //(注意:此 timeout 并非es客户端请求的timeout,后者请在 host_config 中设置) 107 | }, 108 | 109 | /* [必需] 数据传送的规则 */ 110 | riverMap: { 111 | 'users => users': { // [必需] 'a => b' 表示将 mysql数据库中名为 'a' 的 table 的所有数据 输送到 es中名为 'b' 的 type 中去 112 | filter_out: [ // [非必需] 需要过滤的字段名,即 filter_out 中的设置的所有字段将不会被导入 elasticsearch 的数据中 113 | 'password', 114 | 'age' 115 | ], 116 | exception_handler: { // [非必需] 异常处理器,使用JS正则表达式处理异常数据,避免 es 入库时由于类型不合法造成数据缺失 117 | 'birthday': [ // [示例] 对 users 表的 birthday 字段的异常数据进行处理 118 | { 119 | match: /NaN/gi, // [示例] 正则条件(此例匹配字段值为 "NaN" 的情况) 120 | writeAs: null // [示例] 将 "NaN" 重写为 null 121 | }, 122 | { 123 | match: /(\d{4})年/gi, // [示例] 正则表达式(此例匹配字段值为形如 "2016年" 的情况) 124 | writeAs: '$1.1' // [示例] 将 "2016年" 样式的数据重写为 "2016.1" 样式的数据 125 | }, 126 | { 127 | match: /(\d{4})年/gi, // [示例] 正则表达式(此例匹配字段值为形如 "2016年" 的情况) 128 | writeAs: function (word){ // [示例] 用回调函数处理数据 129 | return parseInt(word) + '.1'; 130 | } 131 | } 132 | ] 133 | } 134 | }, 135 | 'favors => favors': { 136 | // [非必需] MySQL 查询语句,将查询结果导入名为 favors 的es类型中,若不配置此项,默认将数据表 favors 的数据导入 es 中 137 | SQL: 'SELECT favors.id,users.name,favors.favor FROM favors,users WHERE favors.user_id = users.id', 138 | filter_out: [ ... ], 139 | exception_handler: { 140 | ... 141 | } 142 | }, 143 | // Other fields' options... 144 | } 145 | 146 | }; 147 | 148 | 149 | /* 150 | ** 将传输结果打印到终端 151 | */ 152 | 153 | esMysqlRiver(river_config, function(obj) { 154 | console.log('\n---------------------------------'); 155 | console.log('总传送:' + obj.total + '项'); 156 | console.log('成功:' + obj.success + '项'); 157 | console.log('失败:' + obj.failed + '项'); 158 | if (obj.result == 'success') { 159 | console.log('\n结论:全部数据传送完成!'); 160 | } else { 161 | console.log('\n结论:传送未成功...'); 162 | } 163 | console.log('---------------------------------'); 164 | }); 165 | ``` 166 | 167 | ##注意事项及参考 168 | 1. elasticsearch 数据导入前请事先导入或配置好 index/type 的数据结构; 169 | 2. ```host_config``` 参数设置详见 [es官方文档](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/configuration.html); 170 | 3. mysql 表的自增 id 自动替换为 ```表名+_id``` 的格式,如:```users_id```; 171 | 4. 如因数据格式或内容问题导致的元数据导入缺失或失败,可通过设置 exception_handler 参数进行包容。 172 | 173 | ##github 项目地址 174 | https://github.com/parksben/mysql_2_elasticsearch 175 | -------------------------------------------------------------------------------- /lib/pull-push.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | 4 | exports.pull = function(pool, table, sqlPhrase, filter_map, exception_handler, callback) { 5 | pool.getConnection(function(err, connection) { 6 | if (err || !connection) { 7 | console.log('数据表:' + table + ' 连接失败!'); 8 | } else { 9 | console.log('数据表:' + table + ' 连接成功...'); 10 | } 11 | 12 | var $sql = {}; 13 | if (sqlPhrase.length > 0) { 14 | $sql.query = sqlPhrase; 15 | } else { 16 | $sql.query = 'select * from ' + table; 17 | } 18 | 19 | connection.query($sql.query, function(err, result) { 20 | if (err || !result) { 21 | console.log('数据表:' + table + ' 读取失败!'); 22 | } else { 23 | console.log('数据表:' + table + ' 读取成功...\n开始从 数据表:' + table + ' 拉取数据,请等待...'); 24 | var resContent = String(JSON.stringify( result )); 25 | console.log('数据拉取完毕!\n开始转换数据格式...'); 26 | json2bulk(resContent, filter_map, exception_handler); 27 | } 28 | 29 | connection.release(); 30 | }); 31 | }); 32 | 33 | var json2bulk = function(originData, filter_map, exception_handler) { 34 | // 去除空格和制表符 35 | var dataStr = String(originData).replace(/(\s+|\t+|\n+|\r+)/gi, ''); 36 | 37 | // 给每一条数据添加配置信息 38 | var dataObj = JSON.parse(dataStr); 39 | 40 | var n = 0; 41 | while (n < dataObj.length) { 42 | var cur_id = dataObj[n].id; 43 | 44 | var dataConf = { 45 | _index: 'parks', 46 | _type: 'developer', 47 | _id: cur_id 48 | }; 49 | dataObj.splice(n, 0, {delete: dataConf}, {create: dataConf}); 50 | 51 | n += 3; 52 | } 53 | 54 | // 对已定义的异常数据进行批处理 55 | var resData = JSON.stringify(dataObj); 56 | 57 | if (exception_handler && Object.keys(exception_handler).length > 0) { 58 | var excHandlers = []; 59 | for (var field in exception_handler) { 60 | for (var i=0;i 0) { 76 | dataObj[j][excHandlers[k].field_name] = dataObj[j][excHandlers[k].field_name].toString().replace(excHandlers[k].patt_exp, excHandlers[k].write_as); 77 | } 78 | } 79 | } 80 | } 81 | 82 | resData = JSON.stringify(dataObj); 83 | } 84 | 85 | // 将数据中出现的 不规范的时间字符串 统统处理为 时间戳 86 | var pattForType_one = /(\"\d\d\d\d\-\d\d\-\d\d)\s?(\d\d\:\d\d(\:\d\d)?\")/gi; 87 | resData = resData.replace(pattForType_one, '$1 $2'); 88 | 89 | resData = resData.replace(pattForType_one, function (item){ 90 | var timeStr = item.substring(1, item.length-1); 91 | return !!Date.parse(timeStr) ? '"' + String(Date.parse(timeStr)) + '"' : 'null'; 92 | }); 93 | 94 | var pattForType_two = /\"\d\d\d\d\.\d+(\.\d+)?\"/gi; 95 | resData = resData.replace(pattForType_two, function (item){ 96 | var timeStr = item.substring(1, item.length-1); 97 | return !!Date.parse(timeStr) ? '"' + String(Date.parse(timeStr)) + '"' : 'null'; 98 | }); 99 | 100 | // 去除文本中 令人头疼的反斜杠 101 | resData = resData.replace(/(\"newsTitle\"\:\"[^\"\\]+)\\([^\"\\]+\")/gi, '$1$2'); 102 | resData = resData.replace(/\\“([^\\]+)\\”/gi, '“$1”'); 103 | 104 | // 去除黑名单字段(river 配置项中的 filter_out) 105 | if (filter_map && filter_map.length > 0) { 106 | for (var i=0;i chunkSize*3) { 155 | var chunkNum = Math.ceil(bulkJson.length / (chunkSize*3)); 156 | console.log('--> 共 ' + parseInt(bulkJson.length/3) + ' 条数据,需上传分片:' + chunkNum + ' 个'); 157 | 158 | var chunks = []; 159 | var chunkState = []; 160 | var requestNum = 0; 161 | 162 | for (var i=0;i 上传分片:' + parseInt(i+1) + '/' + chunkNum + ' 失败!'); 198 | console.log(err.message); 199 | } else { 200 | chunkState[i] = '1'; 201 | console.log('----> 分片:' + parseInt(i+1) + '/' + chunkNum + ' 上传成功!'); 202 | } 203 | 204 | if (i < chunkNum-1) { 205 | requestNum += 1; 206 | importChunk(chunks[requestNum], requestNum); 207 | } else { 208 | checkProcess(); 209 | } 210 | }); 211 | } 212 | } else { 213 | console.log('--> 共 ' + parseInt(bulkJson.length/3) + ' 条数据,需上传分片:1 个'); 214 | 215 | client.bulk({ 216 | body: bulkJson 217 | }, function (err, resp) { 218 | if (err || !resp) { 219 | callback({ 220 | index: es_config.index, 221 | type: es_config.type, 222 | message: 'failed' 223 | }); 224 | } else { 225 | callback({ 226 | index: es_config.index, 227 | type: es_config.type, 228 | message: 'success' 229 | }); 230 | } 231 | 232 | delBulkFile(); 233 | }); 234 | } 235 | 236 | // 清空本地文件的方法 237 | function delBulkFile() { 238 | fs.unlinkSync(path.join(__dirname, '../lib/', es_config.src_table + '.bulk.json')); 239 | } 240 | }); 241 | }; --------------------------------------------------------------------------------