python3 使用canal监听后,kafka数据同步从mysql到clickhouse数据库 完整复盘

python3 使用canal监听后,kafka数据同步从mysql到clickhouse数据库 完整复盘

#前置条件

1、您已经部署了mysql,并打开了bin-log日志,部署了clickhouse,与对应的库表

2、已经部署了kafka,zookeeper

3、已经部署了cannal ,并可以正常将数据写入到kafka中。

项目目录结构 (Project Directory Structure)

复制代码
mysql_to_clickhouse/
├── config.yaml                    # 配置文件 (Configuration file)
├── daemon.py                      # 守护进程脚本 (Daemon process script)
├── daemon.pyc                     # 守护进程编译文件 (Daemon process compiled file)
└── mysql_to_clickhouse.py   # 主程序文件 (Main program file)

文件说明 (File Description)

文件 (File) 类型 (Type) 说明 (Description)
config.yaml 配置文件 存放项目配置参数 (Stores project configuration parameters)
daemon.py Python 脚本 守护进程相关逻辑 (Daemon process related logic)
daemon.pyc Python 编译文件 daemon.py 的编译产物 (Compiled output of daemon.py)
mysql_to_clickhouse.py Python 脚本 MySQL 转 clickhouse 的主程序 (Main program for MySQL to clickhouse conversion)

下面一一来介绍文件内容:

config.yaml

bash 复制代码
#kafka连接信息
kafka:
    #填写主机名
    host: '127.0.0.1:9092' 
    brokers: 'message:9092,web:9092'
    #获取数据库表操作的topic
    database_topic: canal_example
    error_topic: canal_example
    #kafka消费偏移: smallest - 从最早消费; largest - 从最新消费
    offset_reset: largest
    auto_commit : False
# leiting
clickhouse:
    http_api: http://192.168.1.100:8123
    host:
      - ['192.168.1.100',9000]
      - ['192.168.1.100',9000]
    username: default
    password: shuxiaYuesee@72442w
    database: DEISPssfei
    #集群名称
    cluster: shard_cluster

# canal -> ClickHouse 同步配置
sync:
    # canal 推送里的源库名(msg.database),不匹配则丢弃
    source_database: DEISPssfei
    # 6 张待同步表的白名单
    tables:
      - cxx_user
      - cxx_user_info
      - cxx_kings
    # 每张表 MySQL 主键列名(用于 UPDATE/DELETE 时定位行);
    # 不在表里的回退到 canal payload.pkNames[0],再兜底为 'id'。
    pk:
        cxx_user: id
        cxx_user_info: id
        cxx_kings: id

    # MySQL 零时间归一化值,避免 CK DateTime 解析失败
    zero_datetime: '1970-01-01 00:00:00'
    # MySQL 类型 -> CK 类型映射(更新时按需扩展)
    ck_type_map:
        'int(11)': Int32
        'int(10) unsigned': UInt32
        'bigint(20)': Int64
        'bigint(20) unsigned': UInt64
        'tinyint(1)': UInt8
        'tinyint(4)': Int8
        'smallint(6)': Int16
        'varchar(256)': String
        'varchar(128)': String
        'varchar(64)': String
        'varchar(32)': String
        'datetime': DateTime
        'date': Date
        'decimal(10,2)': Decimal(10,2)
    # CK 端列类型与 MySQL 不一致时的覆盖(优先于 ck_type_map 推导)。
    # 例:MySQL depart_id/police_uid 是数值,CK 建表却是 String,
    # 不覆盖会生成不带引号的裸数字,写 String 列报类型错误。
    ck_column_types:
        cxx_kings:
            depart_id: String
            police_uid: String

    # ClickHouse 排序键(ORDER BY ...)。ClickHouse 不允许 ALTER TABLE UPDATE
    # 排序键列,所以这些列从 UPDATE SET 中跳过,只更新非键列。
    # 来源:jianbiao.txt 里每张表的 ORDER BY (...) 子句。
    sort_keys:
        cxx_user: [call_time, alarm_no, id]
        cxx_user_info: [alarm_no, id]
        cxx_kings: [alarm_no, id]
    # ClickHouse 分区键列(PARTITION BY toYYYYMM(x) 里的 x)。
    # 分区键列同样不允许 ALTER TABLE UPDATE,需要与 sort_keys 一起跳过。
    partition_keys:
        cxx_user: [call_time]
        cxx_user_info: [created_at]
        cxx_kings: [created_at]

    # 每张 CK 本地表的列顺序(与 jianbiao.txt 的 CREATE TABLE 保持一致);
    # INSERT 时按此顺序生成列名列表,确保与建表脚本可读一致。
    # canal 推送里没有的列(CK 端有 DEFAULT 表达式的派生列)会被跳过,
    # 由 CK 在写入时按 DEFAULT 自动填充。
    # 顺序说明:id 在最前(便于对齐主键),info_id 紧跟其后(CK 去重主键);
    # 其它列保持建表脚本顺序。
    column_orders:
        cxx_user:
          - id
          - info_id
          - region
          - alarm_no
          - depart_id
          - person_no
          - person_name
          - in_call_time
          - call_time
          - end_call_time
          - call_time_long
          - caller_phone
          - caller_name
          - caller_sex
          - happen_place
          - content
          - manage_depart
          - class_code
          - type_code
          - subclass_code
          - msg_code
          - level
          - bill_status
          - cycs
          - lat
          - lng
          - phone_number
          - caller_idcard
          - created_at
          - updated_at
          - status
          - degree
          - is_repeat
          - attention_uid
        cxx_user_info:
          - id
          - info_id
          - region
          - alarm_no
          - process_no
          - depart_id
          - person_no
          - person_name
          - platform_no
          - platform_ip
          - process_time
          - process_depart_id
          - accpet_person_no
          - accept_person_name
          - over_time
          - send_order_time
          - send_over_time
          - alarm_status
          - created_at
          - updated_at
        cxx_kings:
          - id
          - info_id
          - region
          - alarm_no
          - process_no
          - feedback_no
          - fk_time
          - fk_time_long
          - input_depart_id
          - platform_no
          - platform_ip
          - feedback_person_no
          - feedback_person_name
          - command_name
          - depart_id
          - go_name
          - go_time
          - go_arrive_time
          - class_code
          - type_code
          - subclass_code
          - handle_code
          - is_whether
          - happen_time
          - end_time
          - address_class_code
          - address_type_code
          - desction
          - created_at
          - updated_at
      

daemon.py

python 复制代码
#!/usr/bin/env python
# -*- coding:utf-8 -*-
import atexit
from signal import SIGTERM ,SIGKILL
import os,sys,time,subprocess
class Daemon:
    """
    A generic daemon class.
    
    Usage: subclass the Daemon class and override the run() method
    """
    def __init__(self, pidfile, stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'):
        self.stdin = stdin
        #self.stdout = stdout
        self.stderr = stderr
        self.pidfile = pidfile
    
    def daemonize(self):
        """
        do the UNIX double-fork magic, see Stevens' "Advanced 
        Programming in the UNIX Environment" for details (ISBN 0201563177)
        http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16
        """
        try: 
            pid = os.fork() 
            if pid > 0:
                # exit first parent
                sys.exit(0) 
        except OSError as  e: 
            sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror))
            sys.exit(1)
    
        # decouple from parent environment
        os.chdir(os.path.dirname(os.path.abspath(__file__))) 
        os.setsid() 
        os.umask(0) 
    
        # do second fork
        try: 
            pid = os.fork() 
            if pid > 0:
                # exit from second parent
                sys.exit(0) 
        except OSError as e: 
            sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror))
            sys.exit(1) 
    
        # redirect standard file descriptors
        sys.stdout.flush()
        sys.stderr.flush()
        si = open(self.stdin, 'r')
        #so = file(self.stdout, 'a+')
        se = open(self.stderr, 'ab+', 0)
        os.dup2(si.fileno(), sys.stdin.fileno())
        #os.dup2(so.fileno(), sys.stdout.fileno())
        os.dup2(se.fileno(), sys.stderr.fileno())
    
        # write pidfile
        atexit.register(self.delpid)
        pid = str(os.getpid())
        open(self.pidfile,'w+').write("%s\n" % pid)
    
    def delpid(self):
        os.remove(self.pidfile)

    def kill_child_processes(self,parent_pid, sig=SIGKILL):
        try:
            try:
                pgid = os.getpgid(int(parent_pid))
                #print 'killpg',pgid
                os.killpg(pgid,sig)
            except Exception as e:            
                ps_command = subprocess.Popen("ps -o pid --ppid %d --noheaders" % parent_pid, shell=True, stdout=subprocess.PIPE)
                ps_output = ps_command.stdout.read()
                retcode = ps_command.wait()
                assert retcode == 0, "ps command returned %d" % retcode
                for pid_str in ps_output.split("\n")[:-1]:
                    os.kill(int(pid_str), sig)        
        except AssertionError:
            return
        except Exception as e:            
            return
            
    def check_pid(self,pid):        
        """ Check For the existence of a unix pid. """
        try:
            os.kill(pid, 0)
        except OSError:
            return False
        else:
            return True            
            
    def start(self):
        """
        Start the daemon
        """
        # Check for a pidfile to see if the daemon already runs
        try:
            pf = open(self.pidfile,'r')
            pid = int(pf.read().strip())
            pf.close()
        except IOError:
            pid = None
    
        if pid and self.check_pid(pid):
            message = "pidfile %s already exist. Daemon already running?\n"
            sys.stderr.write(message % self.pidfile)
            sys.exit(1)
        
        # Start the daemon
        self.daemonize()
        self.run()

    def stop(self):
        """
        Stop the daemon
        """
        # Get the pid from the pidfile
        try:
            pf = open(self.pidfile,'r')
            pid = int(pf.read().strip())
            pf.close()
        except IOError:
            pid = None
    
        if not pid:
            message = "pidfile %s does not exist. Daemon not running?\n"
            sys.stderr.write(message % self.pidfile)
            return # not an error in a restart

        # Try killing the daemon process    
        try:
            while 1:
                self.kill_child_processes(pid, SIGTERM)
                os.kill(pid, SIGTERM)
                time.sleep(0.1)
        except OSError as err:
            err = str(err)
            if err.find("No such process") > 0:
                if os.path.exists(self.pidfile):
                    os.remove(self.pidfile)
            else:
                print(str(err))
                sys.exit(1)

    def restart(self):
        """
        Restart the daemon
        """
        self.stop()
        self.start()

    def run(self):
        """
        You should override this method when you subclass Daemon. It will be called after the process has been
        daemonized by start() or restart().
        """

mysql_to_clickhouse.py

python 复制代码
#!/usr/bin/python
#-*- coding: utf-8 -*-
#用于同步mysql到clickhouse数据库
import math
import redis
import pymysql
#import queue as Queue
#import Queue
import yaml,logging,json
from logging.handlers import TimedRotatingFileHandler,RotatingFileHandler
import requests
from datetime import datetime,date,timedelta
import time,timeit,sys,os,threading
from confluent_kafka import Consumer
from confluent_kafka import Producer
from confluent_kafka import KafkaError
from daemon import Daemon
import uuid

def generate_info_id():
    # 生成 UUID 字面字符串(8-4-4-4-12 形式),写入 CK 时配合
    # UUIDStringToNum() 转成 16 字节二进制后存入 info_id(FixedString(16))。
    #
    # 与 PHP 端 SyncRiskAssessPoliceUidUpdateProcess 等效:
    #   UUIDStringToNum('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') -> 16 bytes
    #
    # canal MySQL 源表不存 info_id,首次 INSERT 时由本方法生成;
    # 之后 UPDATE/DELETE 一律不再修改 info_id(详见 _build_alter_update_sql)。
    return str(uuid.uuid4())




class MyDaemon(Daemon):
    
    def api_post(self, sql, timeout=10):
        base_url = self.http_api.rstrip('/')
        url = base_url + '/'

        auth = (self.username, self.password) if self.username else None
        headers = {'Content-Type': 'text/plain; charset=utf-8'}
        r = requests.post(
            url,
            params={'database': self.database},
            data=sql.encode('utf-8'),
            headers=headers,
            auth=auth,
            timeout=timeout
        )
        if r.status_code != 200:
            logging.error('调用clickhouseAPI异常: %s', r.text[:500])
            raise RuntimeError('ck http {}: {}'.format(r.status_code, r.text[:500]))
        return r.text

    def _ck_value(self, value, mysql_type, ck_type=None):
        # null 兜底:字符串类 -> '',数字类 -> 0,日期类 -> zero_dt。
        # ClickHouse 不接受 NULL 的字符串/整数字段(取决于 DEFAULT),直接写
        # 空字符串/0 既符合 CK 端 DEFAULT 约定,也避免下游 JOIN 时类型异常。
        if value is None or value == '' or value == 'NULL' or value == 'null':
            if ck_type:
                ck_t = ck_type.lower()
                if any(k in ck_t for k in ('string', 'fixedstring')):
                    return "''"
                if any(k in ck_t for k in ('int', 'uint', 'float', 'decimal')):
                    return '0'
                if 'datetime' in ck_t:
                    return "'{}'".format(self.zero_dt)
                if ck_t.startswith('date'):
                    return "'1970-01-01'"
            return "''"
        # CK 端列类型优先:MySQL 是数值但 CK 建表是 String 时(如
        # cdp_process_police_alarm_delete.depart_id),必须按 String 加引号。
        if ck_type:
            ck_t = ck_type.lower()
            if any(k in ck_t for k in ('string', 'fixedstring')):
                return "'{}'".format(str(value).replace("'", "\\'"))
        t = (mysql_type or '').lower()
        # datetime:空或 0000-00-00 00:00:00 -> zero_datetime
        if t.startswith('datetime'):
            if value.startswith('0000-') or value == '0000-00-00 00:00:00':
                value = self.zero_dt
            return "'{}'".format(str(value).replace("'", "\\'"))
        if t.startswith('date'):
            if value.startswith('0000-'):
                value = '1970-01-01'
            return "'{}'".format(str(value).replace("'", "\\'"))
        if t.startswith('int') or t.startswith('bigint') or t.startswith('tinyint') or t.startswith('smallint'):
            try:
                # 直接转 int,避免 float 在 >2^53 时丢精度
                s = str(value).strip().replace(',', '')
                return str(int(s))
            except Exception:
                return '0'
        if t.startswith('decimal'):
            try:
                return str(float(value))
            except Exception:
                return '0'
        return "'{}'".format(str(value).replace("'", "\\'"))

    def ck_type_for(self, table, col, mysql_type):
        # CK 列类型:先看配置里的显式覆盖,再按 MySQL 类型映射推导
        override = (self.ck_column_types.get(table) or {}).get(col)
        if override:
            return override
        return self.ck_type_map.get((mysql_type or '').lower())

    def _build_insert_sql(self, table, data, mysql_type_map):
        # canal 推送的单行字典(str -> str)
        info_id = generate_info_id()
        pk_col = self.pk_for_table(table)

        # 优先按配置中的列顺序生成 INSERT,与建表脚本保持一致;
        # canal 推送里没有的列(CK 端 DEFAULT 派生列等)会被跳过,
        # 由 CK 在写入时按 DEFAULT 表达式填充。
        ordered_cols = self.column_orders.get(table)
        if ordered_cols:
            cols = list(ordered_cols)
        else:
            cols = [pk_col, 'info_id'] + [c for c in data.keys()
                                          if c != pk_col and c != 'info_id']

        # canal 通常把 PK 推进 data,但部分版本不会;
        # 保证 PK 列一定出现在 INSERT,否则 CK 会因缺主键报错。
        if pk_col not in cols:
            cols = [pk_col] + cols

        cols_sql = []
        vals = []
        for c in cols:
            if c == pk_col:
                cols_sql.append('`{}`'.format(pk_col))
                vals.append(self._ck_value(data.get(pk_col, '0'),
                                           mysql_type_map.get(pk_col, 'int(11)'),
                                           self.ck_type_for(table, pk_col, mysql_type_map.get(pk_col))))
            elif c == 'info_id':
                # 与 PHP 端 UUIDStringToNum(...) 等效
                cols_sql.append('`info_id`')
                vals.append("UUIDStringToNum('{}')".format(info_id))
            else:
                if c not in data:
                    # canal 未推送的列(CK DEFAULT 派生列),让 CK 自动填充
                    continue
                cols_sql.append('`{}`'.format(c))
                vals.append(self._ck_value(data.get(c),
                                           mysql_type_map.get(c, 'varchar(256)'),
                                           self.ck_type_for(table, c, mysql_type_map.get(c))))
        values_sql = '({})'.format(', '.join(vals))
        table_all = '{}_all'.format(table)
        sql = "INSERT INTO {} ({}) VALUES {}".format(table_all, ', '.join(cols_sql), values_sql)
        return sql

    def _build_alter_update_sql(self, table, after, mysql_type_map, pk_name):
        pk_value = after.get(pk_name)
        if not pk_value:
            raise ValueError('update event missing pk {} for table {}'.format(pk_name, table))

        # ClickHouse 排序键(ORDER BY ...)和分区键(PARTITION BY ...)都不可
        # UPDATE,需要一起跳过;否则报 Cannot UPDATE key column。
        skip_cols = set(self.sort_keys.get(table, []))
        skip_cols |= set(self.partition_keys.get(table, []))
        # date 是 CK 端的 DEFAULT 派生列(toDate(分区列)),canal 不推,也不更新
        skip_cols.add('date')

        # 全量 SET:按主键定位行后,把所有非 PK、非 info_id、非排序键的列
        # 直接写成 after 里的新值。不做 diff 比对 ------ canal 推的 UPDATE
        # 本就是「按 PK 把整行刷新」的语义,无意义 SET 也只是写等值,代价可忽略。
        # info_id 在首次 INSERT 时生成,UPDATE 时不动,避免破坏 CK 端的去重主键。
        set_parts = []
        for col in after.keys():
            if col == pk_name or col == 'info_id' or col in skip_cols:
                continue
            ck_v = self._ck_value(after.get(col),
                                  mysql_type_map.get(col, 'varchar(256)'),
                                  self.ck_type_for(table, col, mysql_type_map.get(col)))
            set_parts.append('`{}` = {}'.format(col, ck_v))

        if not set_parts:
            logging.warning('update event has no settable cols, skip. table=%s id=%s',
                            table, pk_value)
            return None

        pk_ck = self._ck_value(pk_value, mysql_type_map.get(pk_name, 'int(11)'),
                               self.ck_type_for(table, pk_name, mysql_type_map.get(pk_name)))
        sql = ("ALTER TABLE `{}` ON CLUSTER {} UPDATE {} "
               "WHERE `{}` = {}").format(table, self.cluster, ', '.join(set_parts),
                                         pk_name, pk_ck)
        return sql


    def _build_alter_delete_sql(self, table, data, mysql_type_map, pk_name):
        pk_value = data.get(pk_name)
        if not pk_value:
            raise ValueError('delete event missing pk {} for table {}'.format(pk_name, table))
        pk_ck = self._ck_value(pk_value, mysql_type_map.get(pk_name, 'int(11)'),
                               self.ck_type_for(table, pk_name, mysql_type_map.get(pk_name)))
        sql = ("ALTER TABLE `{}` ON CLUSTER {} DELETE "
               "WHERE `{}` = {}").format(table, self.cluster, pk_name, pk_ck)
        return sql    

    def _delivery_callback(self, err, msg):
        """producer callback (poll or flush)"""
        if err:
            logging.error('Message failed delivery: %s' % err)

    def _exec_write(self, sql ,msg ):
        try:
            ret = self.api_post(sql)
            body = (ret or '').strip()
            # CK HTTP 成功时 body 是 "table\trows\n";只在出现异常关键字时升级日志
            if body and any(k in body.lower() for k in ('exception', 'error', 'failed')):
                logging.warning('ck write resp: %s | sql: %s', body, sql[:200])
            else:
                logging.info('ok')
        except Exception as e:
            logging.exception('ck write failed. sql=%s err=%s', sql, e)
            logging.info('重新回推到kafka')
            payload = json.dumps(msg,ensure_ascii=False).encode('utf-8')
            self.producer.produce(self.config['kafka']['error_topic'],payload,callback=self._delivery_callback,)
            self.producer.poll(0)
            remaing = self.producer.flush(timeout=5)
            if remaing > 0 :
                logging.error("kafka flush timeout")




    def _parse_pk_name(self,pk_names):
        return pk_names[0] if pk_names else None
    
    """取表的主键列名:配置优先,否则 canal 推的 pkNames,否则 'id'"""
    def pk_for_table(self, table, fallback_pk=None):
        return self.pk_map.get(table) or fallback_pk or 'id'

    def handle(self, msg):
       
        database = msg.get('database', '')
        table = msg.get('table', '')
        event_type = msg.get('type', '')
        mysql_type_map = msg.get('mysqlType', {}) or {}
        pk_names = msg.get('pkNames', []) or []
        data_list = msg.get('data', []) or []
        
        if database != self.source_database:
            logging.error('数据库未定义')
            return False
        if table not in self.sync_tables:
            logging.error('同步表未定义')
            return False
        if not data_list:
            logging.error('kafka数据没有data自定义数据')
            return False
        
        pk_name = self.pk_for_table(table, fallback_pk=self._parse_pk_name(pk_names))

        for row in data_list:
            try:
                if event_type == 'INSERT':
                    sql = self._build_insert_sql(table, row, mysql_type_map)
                    self._exec_write(sql,msg)
                elif event_type == 'UPDATE':
                    # canal: 'data' 是新值字典;不再读 'old',直接全量 SET
                    sql = self._build_alter_update_sql(table, row, mysql_type_map, pk_name)
                    if sql:
                        self._exec_write(sql,msg)
                elif event_type == 'DELETE':
                    sql = self._build_alter_delete_sql(table, row, mysql_type_map, pk_name)
                    self._exec_write(sql,msg)
                else:
                    logging.warning('unknown event type: %s', event_type)
            except Exception as e:
                logging.exception('handle row failed. table=%s type=%s err=%s',
                                  table, event_type, e)
    
    def get_kafka_message(self):
        consumer = None
        # 默认改手动 commit,处理成功才提交,避免重启时重复消费
        enable_auto_commit = bool(self.config['kafka'].get('auto_commit', False))
        consumer_conf = {
            'bootstrap.servers': self.config['kafka']['host'],
            'group.id': 'sync_ck_20260808133600',#当前脚本的名称_日期精确到秒
            'client.id': 'sync_ck_20260808133600',#当前脚本的名称_日期精确到秒
            'auto.offset.reset': self.config['kafka']['offset_reset'],
            'enable.auto.commit': enable_auto_commit
        }
        consumer = Consumer(**consumer_conf)
        def print_assignment(consumer, partitions):
            logging.info("Assignment: {}".format(partitions))
        consumer.subscribe([self.config['kafka']['database_topic']], on_assign=print_assignment)
         
        while 1:
            try:
                #每次消费10条数据
                messages = consumer.consume(num_messages=1,timeout=1.0)
                #无数据或者超时可能返回空
                if not messages:
                    time.sleep(0.01)
                    continue
                for message in messages:
                    partition = message.partition()
                    offset = message.offset()
                    #检验Message对象
                    if message.error():
                        if message.error().code() == KafkaError._PARTITION_EOF:
                            logging.info('partition: %d reached end at offset %d.', partition, offset)
                            pass
                        else:
                            logging.error("kafka consumer error! {}".format(message.error()))
                        continue
                    row = message.value()
                    if row is not None:
                        try :
                            result = json.loads(row.decode('utf-8'))
                            #logging.info(json.dumps(result,ensure_ascii=False)) #转成json 中文不转义
                        except Exception as e:
                            logging.error('kafka数据处理失败: %s', str(e))
                            continue
                        if result:
                            #处理sql 
                            logging.info(result)
                            self.handle(result)

            except KeyboardInterrupt:
                logging.error('Ctrl+C,终止运行')
                #停止消费,提交偏移量
                consumer.close()
                return
            except Exception as e:
                logging.exception('连接kafka时错误: %s', str(e))
                time.sleep(1)
            
        logging.warning('consumer thread exited')
  
    

    def run(self):
        with open(os.path.dirname(os.path.abspath(__file__)) + '/config.yaml') as config_file:
            self.config = yaml.safe_load(config_file)
            
        name = 'mysql_to_clickhouse' 
        logging.basicConfig(level=logging.INFO) 
        logging.getLogger("requests").setLevel(logging.WARNING)
        handler = RotatingFileHandler('/var/log/%s.log' % name, maxBytes=134217728, backupCount=7)
        formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
        handler.setFormatter(formatter)
        logging.getLogger('').addHandler(handler)
        logging.info('启动 [%s]', name)
        self.db = None
        self.queen_size = 500
        
        self.http_api = self.config['clickhouse']['http_api'].rstrip('/')     #clickhouse数据库api
        self.username = self.config['clickhouse'].get('username', 'default')  #数据库用户名
        self.password = self.config['clickhouse']['password']
        self.database = self.config['clickhouse']['database']
        self.cluster = self.config['clickhouse']['cluster']
        sync_cfg = self.config.get('sync', {})
        self.sync_tables = set(sync_cfg.get('tables', []))
        self.source_database = sync_cfg.get('source_database', 'DEISPssfei')
        self.zero_dt = sync_cfg.get('zero_datetime', '1970-01-01 00:00:00')
        self.column_orders = sync_cfg.get('column_orders', {})
        # 各表 MySQL 主键列名;UPDATE/DELETE 用它定位行,INSERT 用它选 PK 列
        self.pk_map = sync_cfg.get('pk', {})
        # MySQL 类型 -> CK 类型映射;null 兜底时按 CK 类型决定默认值
        self.ck_type_map = sync_cfg.get('ck_type_map', {})
        # CK 端列类型覆盖(MySQL 与 CK 类型不一致的列)
        self.ck_column_types = sync_cfg.get('ck_column_types', {})
        # ClickHouse 排序键(ORDER BY ...),UPDATE 时跳过这些列
        self.sort_keys = sync_cfg.get('sort_keys', {})
        # ClickHouse 分区键列(PARTITION BY ...),UPDATE 时同样跳过
        self.partition_keys = sync_cfg.get('partition_keys', {})

        self.producer = Producer ({
            'bootstrap.servers': self.config['kafka']['brokers']
        })
        
        try:
            
            #监控相似度参数
            t = threading.Thread(target=self.get_kafka_message)
            t.setDaemon(True)
            t.start()
            
            logging.warning('进行Mysql到clickhouse的同步...')
            while 1:
                try:
                    time.sleep(600)
                    logging.info('running')
                except KeyboardInterrupt:
                    logging.error('Ctrl+C,终止运行')
                    sys.exit(1)            
                except Exception as e:
                    logging.exception('连接kafka时错误: %s', str(e))
                    time.sleep(1)
        except Exception as e:
            logging.exception('同步Mysql数据到clickhouse异常: %s', str(e))
            sys.exit(1)
    
if __name__ == "__main__":
    daemon = MyDaemon('/var/run/mysql_to_clickhouse.pid')
    #daemon.run()
    #sys.exit(1)
    if len(sys.argv) == 2:
        if 'start' == sys.argv[1]:
            daemon.start()
        elif 'stop' == sys.argv[1]:
            daemon.stop()
        elif 'restart' == sys.argv[1]:
            daemon.restart()
        else:
            print("Unknown command")
            sys.exit(2)
        sys.exit(0)
    else:
        print("usage: %s start|stop|restart" % sys.argv[0])
        sys.exit(2)

运行:python3 mysql_to_lightningdb.py start

日志:tailf /var/log/mysql_to_lightningdb.log

相关推荐
深念Y11 分钟前
QQ 数据库解密与聊天记录导出 — 技术文档
数据库·sqlite·手机·数据恢复·逆向·二进制·qq
程序员-Benothing13 分钟前
什么是批量数据入库?相比单条插入有什么优势?
数据库
我滴老baby18 分钟前
部署 Portainer CE,把日志、镜像和数据卷搬进网页
数据库·人工智能·架构
东风破_30 分钟前
博客数据库进阶设计:点赞、收藏、评论、标签与文件表怎么建?
后端·mysql
吠品34 分钟前
TortoiseSVN 状态图标不显示的排查与解决办法
java·服务器·数据库
ltl38 分钟前
PostgreSQL WAL 内部机制:XLogInsert 到 Checkpoint
数据库
cetcht888839 分钟前
深耕配电智能化升级 多场景项目落地筑牢电力运维安全防线
大数据·数据库·人工智能
ltl42 分钟前
RocksDB Universal 与 Write Stall:L0 积压怎么触发 DelayWrite
数据库
ltl43 分钟前
CockroachDB 对照:Range + Raft 的另一种 HTAP 落地
数据库