mdbq 3.10.11__py3-none-any.whl → 3.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mdbq/__version__.py CHANGED
@@ -1 +1 @@
1
- VERSION = '3.10.11'
1
+ VERSION = '3.11.0'
@@ -12,6 +12,7 @@ import threading
12
12
  import concurrent.futures
13
13
  from collections import defaultdict
14
14
  import sys
15
+ from datetime import datetime
15
16
 
16
17
 
17
18
  warnings.filterwarnings('ignore')
@@ -77,15 +78,18 @@ class MySQLDeduplicator:
77
78
  date_range: Optional[List[str]] = None,
78
79
  recent_month: Optional[int] = None,
79
80
  date_column: str = '日期',
80
- exclude_columns: Optional[List[str]] = None
81
+ exclude_columns: Optional[List[str]] = None,
82
+ exclude_databases: Optional[List[str]] = None,
83
+ exclude_tables: Optional[Dict[str, List[str]]] = None
81
84
  ) -> None:
82
85
  """
83
86
  初始化去重处理器
84
- 新增参数:
85
87
  :param date_range: 指定去重的日期区间 [start_date, end_date],格式'YYYY-MM-DD'
86
88
  :param recent_month: 最近N个月的数据去重(与date_range互斥,优先生效)
87
89
  :param date_column: 时间列名,默认为'日期'
88
90
  :param exclude_columns: 去重时排除的列名列表,默认为['id', '更新时间']
91
+ :param exclude_databases: 排除的数据库名列表
92
+ :param exclude_tables: 排除的表名字典 {数据库名: [表名, ...]}
89
93
  """
90
94
  # 连接池状态标志
91
95
  self._closed = False
@@ -127,11 +131,28 @@ class MySQLDeduplicator:
127
131
  self.exclude_columns = list(default_exclude | {'更新时间'})
128
132
  else:
129
133
  self.exclude_columns = list(set(exclude_columns) | default_exclude)
130
- # 解析时间范围
134
+ # 解析时间范围并智能校正date_range
131
135
  if self.date_range and len(self.date_range) == 2:
132
- self._dedup_start_date, self._dedup_end_date = self.date_range
136
+ try:
137
+ start, end = self.date_range
138
+ start_dt = datetime.strptime(start, "%Y-%m-%d")
139
+ end_dt = datetime.strptime(end, "%Y-%m-%d")
140
+ if start_dt > end_dt:
141
+ logger.warning(
142
+ "date_range顺序不正确,自动交换开始和结束日期。",
143
+ {"start": start, "end": end}
144
+ )
145
+ start_dt, end_dt = end_dt, start_dt
146
+ self._dedup_start_date = start_dt.strftime("%Y-%m-%d")
147
+ self._dedup_end_date = end_dt.strftime("%Y-%m-%d")
148
+ except Exception as e:
149
+ logger.error(
150
+ "date_range参数格式错误,应为['YYYY-MM-DD', 'YYYY-MM-DD'],已忽略时间范围。",
151
+ {"date_range": self.date_range, "error": str(e)}
152
+ )
153
+ self._dedup_start_date = None
154
+ self._dedup_end_date = None
133
155
  elif self.recent_month:
134
- from datetime import datetime, timedelta
135
156
  today = datetime.today()
136
157
  month = today.month - self.recent_month
137
158
  year = today.year
@@ -148,8 +169,19 @@ class MySQLDeduplicator:
148
169
  # 系统数据库列表
149
170
  self.SYSTEM_DATABASES = {'information_schema', 'mysql', 'performance_schema', 'sys'}
150
171
 
172
+ # 排除数据库和表的逻辑
173
+ self.exclude_databases = set([db.lower() for db in exclude_databases]) if exclude_databases else set()
174
+ self.exclude_tables = {k.lower(): set([t.lower() for t in v]) for k, v in (exclude_tables or {}).items()}
175
+
151
176
  def _get_connection(self) -> pymysql.connections.Connection:
152
- """从连接池获取连接"""
177
+ """
178
+ 从连接池获取一个数据库连接。
179
+
180
+ Returns:
181
+ pymysql.connections.Connection: 数据库连接对象。
182
+ Raises:
183
+ ConnectionError: 如果连接池已关闭或获取连接失败。
184
+ """
153
185
  if self._closed:
154
186
  logger.error('尝试获取连接但连接池已关闭')
155
187
  raise ConnectionError("连接池已关闭")
@@ -163,7 +195,17 @@ class MySQLDeduplicator:
163
195
 
164
196
  @staticmethod
165
197
  def _retry_on_failure(func: Any) -> Any:
166
- """重试装饰器"""
198
+ """
199
+ 装饰器:为数据库操作方法提供自动重试机制。
200
+ 仅捕获pymysql的连接相关异常,重试指定次数后抛出最后一次异常。
201
+
202
+ Args:
203
+ func (Any): 被装饰的函数。
204
+ Returns:
205
+ Any: 被装饰函数的返回值。
206
+ Raises:
207
+ Exception: 多次重试后依然失败时抛出。
208
+ """
167
209
  @wraps(func)
168
210
  def wrapper(self, *args, **kwargs):
169
211
  last_exception = None
@@ -192,21 +234,31 @@ class MySQLDeduplicator:
192
234
 
193
235
  @_retry_on_failure
194
236
  def _get_databases(self) -> List[str]:
195
- """获取所有非系统数据库列表"""
237
+ """
238
+ 获取所有非系统数据库列表,排除exclude_databases。
239
+
240
+ Returns:
241
+ List[str]: 数据库名列表。
242
+ """
196
243
  sql = "SHOW DATABASES"
197
-
198
244
  with self._get_connection() as conn:
199
245
  with conn.cursor() as cursor:
200
246
  cursor.execute(sql)
201
247
  all_dbs = [row['Database'] for row in cursor.fetchall()]
202
-
203
- if self.skip_system_dbs:
204
- return [db for db in all_dbs if db.lower() not in self.SYSTEM_DATABASES]
205
- return all_dbs
248
+ # 排除系统库和用户指定的库
249
+ filtered = [db for db in all_dbs if db.lower() not in self.SYSTEM_DATABASES and db.lower() not in self.exclude_databases] if self.skip_system_dbs else [db for db in all_dbs if db.lower() not in self.exclude_databases]
250
+ return filtered
206
251
 
207
252
  @_retry_on_failure
208
253
  def _get_tables(self, database: str) -> List[str]:
209
- """获取指定数据库的所有表"""
254
+ """
255
+ 获取指定数据库的所有表名。
256
+
257
+ Args:
258
+ database (str): 数据库名。
259
+ Returns:
260
+ List[str]: 表名列表。
261
+ """
210
262
  sql = "SHOW TABLES"
211
263
 
212
264
  with self._get_connection() as conn:
@@ -217,7 +269,15 @@ class MySQLDeduplicator:
217
269
 
218
270
  @_retry_on_failure
219
271
  def _get_table_columns(self, database: str, table: str) -> List[str]:
220
- """获取表的列名(排除主键列)"""
272
+ """
273
+ 获取指定表的所有列名(排除主键列)。
274
+
275
+ Args:
276
+ database (str): 数据库名。
277
+ table (str): 表名。
278
+ Returns:
279
+ List[str]: 列名列表。
280
+ """
221
281
  sql = """
222
282
  SELECT COLUMN_NAME
223
283
  FROM INFORMATION_SCHEMA.COLUMNS
@@ -232,7 +292,15 @@ class MySQLDeduplicator:
232
292
  if row['COLUMN_NAME'].lower() != self.primary_key.lower()]
233
293
 
234
294
  def _acquire_table_lock(self, database: str, table: str) -> bool:
235
- """获取表处理锁,防止并发处理同一张表"""
295
+ """
296
+ 获取表处理锁,防止并发处理同一张表。
297
+
298
+ Args:
299
+ database (str): 数据库名。
300
+ table (str): 表名。
301
+ Returns:
302
+ bool: 是否成功获取锁。
303
+ """
236
304
  key = f"{database}.{table}"
237
305
 
238
306
  with self._lock:
@@ -243,7 +311,13 @@ class MySQLDeduplicator:
243
311
  return True
244
312
 
245
313
  def _release_table_lock(self, database: str, table: str) -> None:
246
- """释放表处理锁"""
314
+ """
315
+ 释放表处理锁。
316
+
317
+ Args:
318
+ database (str): 数据库名。
319
+ table (str): 表名。
320
+ """
247
321
  key = f"{database}.{table}"
248
322
 
249
323
  with self._lock:
@@ -258,13 +332,15 @@ class MySQLDeduplicator:
258
332
  dry_run: bool = False
259
333
  ) -> Tuple[int, int]:
260
334
  """
261
- 执行单表去重
262
-
263
- :param database: 数据库名
264
- :param table: 表名
265
- :param columns: 用于去重的列(为None时使用所有列)
266
- :param dry_run: 是否模拟运行(只统计不实际删除)
267
- :return: (重复行数, 删除行数)
335
+ 执行单表去重。
336
+
337
+ Args:
338
+ database (str): 数据库名。
339
+ table (str): 表名。
340
+ columns (Optional[List[str]]): 用于去重的列名列表(为None时使用所有列)。
341
+ dry_run (bool): 是否为模拟运行(只统计不实际删除)。
342
+ Returns:
343
+ Tuple[int, int]: (重复组数, 实际删除行数)。
268
344
  """
269
345
  if not self._acquire_table_lock(database, table):
270
346
  return (0, 0)
@@ -385,14 +461,19 @@ class MySQLDeduplicator:
385
461
  dry_run: bool = False
386
462
  ) -> Tuple[int, int]:
387
463
  """
388
- 对指定表进行去重
389
-
390
- :param database: 数据库名
391
- :param table: 表名
392
- :param columns: 用于去重的列(为None时使用所有列)
393
- :param dry_run: 是否模拟运行(只统计不实际删除)
394
- :return: (重复行数, 删除行数)
464
+ 对指定表进行去重。
465
+
466
+ Args:
467
+ database (str): 数据库名。
468
+ table (str): 表名。
469
+ columns (Optional[List[str]]): 用于去重的列名列表(为None时使用所有列)。
470
+ dry_run (bool): 是否为模拟运行(只统计不实际删除)。
471
+ Returns:
472
+ Tuple[int, int]: (重复组数, 实际删除行数)。
395
473
  """
474
+ if database.lower() in self.exclude_tables and table.lower() in self.exclude_tables[database.lower()]:
475
+ logger.info('表被排除', {"库": database, "表": table, "操作": "跳过"})
476
+ return (0, 0)
396
477
  try:
397
478
  if not self._check_table_exists(database, table):
398
479
  logger.warning('表不存在', {"库": database, "表": table, "warning": "跳过"})
@@ -414,14 +495,16 @@ class MySQLDeduplicator:
414
495
  parallel: bool = False
415
496
  ) -> Dict[str, Tuple[int, int]]:
416
497
  """
417
- 对指定数据库的所有表进行去重
418
-
419
- :param database: 数据库名
420
- :param tables: 要处理的表列表(为None时处理所有表)
421
- :param columns_map: 各表使用的去重列 {表名: [列名]}
422
- :param dry_run: 是否模拟运行
423
- :param parallel: 是否并行处理
424
- :return: 字典 {表名: (重复行数, 删除行数)}
498
+ 对指定数据库的所有表进行去重。
499
+
500
+ Args:
501
+ database (str): 数据库名。
502
+ tables (Optional[List[str]]): 要处理的表列表(为None时处理所有表)。
503
+ columns_map (Optional[Dict[str, List[str]]]): 各表使用的去重列 {表名: [列名]}。
504
+ dry_run (bool): 是否为模拟运行。
505
+ parallel (bool): 是否并行处理。
506
+ Returns:
507
+ Dict[str, Tuple[int, int]]: {表名: (重复组数, 实际删除行数)}。
425
508
  """
426
509
  results = {}
427
510
  try:
@@ -429,6 +512,8 @@ class MySQLDeduplicator:
429
512
  logger.warning('数据库不存在', {"库": database})
430
513
  return results
431
514
  target_tables = tables or self._get_tables(database)
515
+ exclude_tbls = self.exclude_tables.get(database.lower(), set())
516
+ target_tables = [t for t in target_tables if t.lower() not in exclude_tbls]
432
517
  logger.debug('获取目标表', {'库': database, 'tables': target_tables})
433
518
  if not target_tables:
434
519
  logger.info('数据库中没有表', {"库": database, "操作": "跳过"})
@@ -482,18 +567,21 @@ class MySQLDeduplicator:
482
567
  parallel: bool = False
483
568
  ) -> Dict[str, Dict[str, Tuple[int, int]]]:
484
569
  """
485
- 对所有数据库进行去重
486
-
487
- :param databases: 要处理的数据库列表(为None时处理所有非系统数据库)
488
- :param tables_map: 各数据库要处理的表 {数据库名: [表名]}
489
- :param columns_map: 各表使用的去重列 {数据库名: {表名: [列名]}}
490
- :param dry_run: 是否模拟运行
491
- :param parallel: 是否并行处理
492
- :return: 嵌套字典 {数据库名: {表名: (重复行数, 删除行数)}}
570
+ 对所有数据库进行去重。
571
+
572
+ Args:
573
+ databases (Optional[List[str]]): 要处理的数据库列表。如果为 None,则处理所有非系统数据库。
574
+ tables_map (Optional[Dict[str, List[str]]]): 指定每个数据库要处理的表,格式为 {数据库名: [表名, ...]}。如果为 None,则处理所有表。
575
+ columns_map (Optional[Dict[str, Dict[str, List[str]]]]): 指定每个表去重时使用的列,格式为 {数据库名: {表名: [列名, ...]}}。如果为 None,则使用所有列。
576
+ dry_run (bool): 是否为模拟运行模式。为 True 时只统计重复行数,不实际删除。
577
+ parallel (bool): 是否并行处理多个数据库。为 True 时使用线程池并发处理。
578
+ Returns:
579
+ Dict[str, Dict[str, Tuple[int, int]]]: 嵌套字典,格式为 {数据库名: {表名: (重复组数, 实际删除行数)}}。
493
580
  """
494
- all_results = defaultdict(dict)
581
+ all_results: Dict[str, Dict[str, Tuple[int, int]]] = defaultdict(dict)
495
582
  try:
496
- target_dbs = databases or self._get_databases()
583
+ target_dbs: List[str] = databases or self._get_databases()
584
+ target_dbs = [db for db in target_dbs if db.lower() not in self.exclude_databases]
497
585
  logger.debug('获取目标数据库', {'databases': target_dbs})
498
586
  if not target_dbs:
499
587
  logger.warning('没有可处理的数据库')
@@ -504,7 +592,7 @@ class MySQLDeduplicator:
504
592
  with concurrent.futures.ThreadPoolExecutor(
505
593
  max_workers=self.max_workers
506
594
  ) as executor:
507
- futures = {}
595
+ futures: Dict[concurrent.futures.Future, str] = {}
508
596
  for db in target_dbs:
509
597
  tables = tables_map.get(db) if tables_map else None
510
598
  db_columns_map = columns_map.get(db) if columns_map else None
@@ -545,7 +633,14 @@ class MySQLDeduplicator:
545
633
 
546
634
  @_retry_on_failure
547
635
  def _check_database_exists(self, database: str) -> bool:
548
- """检查数据库是否存在"""
636
+ """
637
+ 检查数据库是否存在。
638
+
639
+ Args:
640
+ database (str): 数据库名。
641
+ Returns:
642
+ bool: 数据库是否存在。
643
+ """
549
644
  sql = "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = %s"
550
645
 
551
646
  with self._get_connection() as conn:
@@ -555,7 +650,15 @@ class MySQLDeduplicator:
555
650
 
556
651
  @_retry_on_failure
557
652
  def _check_table_exists(self, database: str, table: str) -> bool:
558
- """检查表是否存在"""
653
+ """
654
+ 检查表是否存在。
655
+
656
+ Args:
657
+ database (str): 数据库名。
658
+ table (str): 表名。
659
+ Returns:
660
+ bool: 表是否存在。
661
+ """
559
662
  sql = """
560
663
  SELECT TABLE_NAME
561
664
  FROM INFORMATION_SCHEMA.TABLES
@@ -568,7 +671,12 @@ class MySQLDeduplicator:
568
671
  return bool(cursor.fetchone())
569
672
 
570
673
  def close(self) -> None:
571
- """关闭连接池"""
674
+ """
675
+ 关闭连接池。
676
+
677
+ Returns:
678
+ None
679
+ """
572
680
  try:
573
681
  if hasattr(self, 'pool') and self.pool and not self._closed:
574
682
  self.pool.close()
@@ -580,9 +688,25 @@ class MySQLDeduplicator:
580
688
  logger.error(f"关闭连接池时出错", {'error_type': type(e).__name__, 'error': str(e)})
581
689
 
582
690
  def __enter__(self) -> 'MySQLDeduplicator':
691
+ """
692
+ 上下文管理器进入方法。
693
+
694
+ Returns:
695
+ MySQLDeduplicator: 实例自身。
696
+ """
583
697
  return self
584
698
 
585
699
  def __exit__(self, exc_type: Optional[type], exc_val: Optional[BaseException], exc_tb: Optional[Any]) -> None:
700
+ """
701
+ 上下文管理器退出方法,自动关闭连接池。
702
+
703
+ Args:
704
+ exc_type (Optional[type]): 异常类型。
705
+ exc_val (Optional[BaseException]): 异常值。
706
+ exc_tb (Optional[Any]): 异常追踪。
707
+ Returns:
708
+ None
709
+ """
586
710
  self.close()
587
711
 
588
712
 
mdbq/mysql/uploader.py CHANGED
@@ -346,8 +346,8 @@ class MySQLUploader:
346
346
  logger.error('无效的标识符', {'标识符': identifier})
347
347
  raise ValueError(f"无效的标识符: `{identifier}`")
348
348
  if not self.case_sensitive:
349
- identifier = identifier.lower()
350
- cleaned = re.sub(r'[^-\w\u4e00-\u9fff$]', '_', identifier)
349
+
350
+ cleaned = re.sub(r'[^\w\u4e00-\u9fff$]', '_', identifier)
351
351
  cleaned = re.sub(r'_+', '_', cleaned).strip('_')
352
352
  if not cleaned:
353
353
  logger.error('无法清理异常标识符', {'原始标识符': identifier})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: mdbq
3
- Version: 3.10.11
3
+ Version: 3.11.0
4
4
  Home-page: https://pypi.org/project/mdbq
5
5
  Author: xigua,
6
6
  Author-email: 2587125111@qq.com
@@ -1,7 +1,6 @@
1
1
  mdbq/__init__.py,sha256=Il5Q9ATdX8yXqVxtP_nYqUhExzxPC_qk_WXQ_4h0exg,16
2
- mdbq/__version__.py,sha256=9LS36YFJz4Ep6F-NYjHbq6pXXVJAhfLpffZVlSwjaSI,19
2
+ mdbq/__version__.py,sha256=vrerkwEDL5E5nSSdgLRAwkxPSJb14-hLfjbjj6J7G3I,18
3
3
  mdbq/aggregation/__init__.py,sha256=EeDqX2Aml6SPx8363J-v1lz0EcZtgwIBYyCJV6CcEDU,40
4
- mdbq/aggregation/optimize.py,sha256=zC_w_aVYXwmvfF0Z8iSGMmv5vptF0rP-Dz5zmp0gXTU,19820
5
4
  mdbq/aggregation/query_data.py,sha256=fdotW8qdAyDB13p7r3p6AGBkavcHnf6hIvSMtcS7vqE,179875
6
5
  mdbq/config/__init__.py,sha256=jso1oHcy6cJEfa7udS_9uO5X6kZLoPBF8l3wCYmr5dM,18
7
6
  mdbq/config/config.py,sha256=eaTfrfXQ65xLqjr5I8-HkZd_jEY1JkGinEgv3TSLeoQ,3170
@@ -9,10 +8,10 @@ mdbq/log/__init__.py,sha256=Mpbrav0s0ifLL7lVDAuePEi1hJKiSHhxcv1byBKDl5E,15
9
8
  mdbq/log/mylogger.py,sha256=07sstIeaIQUJXwpMwmxppRI7kW7QwZFnv4Rr3UDlyUs,24133
10
9
  mdbq/log/spider_logging.py,sha256=-ozWWEGm3HVv604ozs_OOvVwumjokmUPwbaodesUrPY,1664
11
10
  mdbq/mysql/__init__.py,sha256=A_DPJyAoEvTSFojiI2e94zP0FKtCkkwKP1kYUCSyQzo,11
12
- mdbq/mysql/deduplicator.py,sha256=sm99eneNO7Br21BH-8vnZW3b7jA3gPF7c9Bvz04YV_g,27759
11
+ mdbq/mysql/deduplicator.py,sha256=ibmxpzenhPgT_ei61TjQB2ZxYs9ztkG_ygbLSa8RIlM,32990
13
12
  mdbq/mysql/mysql.py,sha256=Lfy9PsEdgmdRtcG_UUgegH3bFTJPhByTWkcAYl8G6m0,56788
14
13
  mdbq/mysql/s_query.py,sha256=dlnrVJ3-Vp1Suv9CNbPxyYSRqRJUHjOpF39tb2F-wBc,10190
15
- mdbq/mysql/uploader.py,sha256=CyNrNTGBvxwfG5NygG1tnsgVTqfKw_U5BewFZvObhJ0,66485
14
+ mdbq/mysql/uploader.py,sha256=6b1NXGtQnhpSWMXnw0ai07ejS1GzMUZWzcjG8G68pbY,66451
16
15
  mdbq/other/__init__.py,sha256=jso1oHcy6cJEfa7udS_9uO5X6kZLoPBF8l3wCYmr5dM,18
17
16
  mdbq/other/download_sku_picture.py,sha256=YU8DxKMXbdeE1OOKEA848WVp62jYHw5O4tXTjUdq9H0,44832
18
17
  mdbq/other/otk.py,sha256=iclBIFbQbhlqzUbcMMoePXBpcP1eZ06ZtjnhcA_EbmE,7241
@@ -25,7 +24,7 @@ mdbq/redis/__init__.py,sha256=YtgBlVSMDphtpwYX248wGge1x-Ex_mMufz4-8W0XRmA,12
25
24
  mdbq/redis/getredis.py,sha256=YHgCKO8mEsslwet33K5tGss-nrDDwPnOSlhA9iBu0jY,24078
26
25
  mdbq/spider/__init__.py,sha256=RBMFXGy_jd1HXZhngB2T2XTvJqki8P_Fr-pBcwijnew,18
27
26
  mdbq/spider/aikucun.py,sha256=YyPWa_nOH1zs8wgTDcgzn5w8szGKWPyWzmWMVIPkFnU,21638
28
- mdbq-3.10.11.dist-info/METADATA,sha256=GrJ6G6ZtBfCsHMzbSkBzWYn_GIPa_W_rAI6tSLK6Pjs,365
29
- mdbq-3.10.11.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
30
- mdbq-3.10.11.dist-info/top_level.txt,sha256=2FQ-uLnCSB-OwFiWntzmwosW3X2Xqsg0ewh1axsaylA,5
31
- mdbq-3.10.11.dist-info/RECORD,,
27
+ mdbq-3.11.0.dist-info/METADATA,sha256=go0OKEPPSfRYS3OQJ0A2bJP1FPNCbtAtp-LHZlrh9NM,364
28
+ mdbq-3.11.0.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
29
+ mdbq-3.11.0.dist-info/top_level.txt,sha256=2FQ-uLnCSB-OwFiWntzmwosW3X2Xqsg0ewh1axsaylA,5
30
+ mdbq-3.11.0.dist-info/RECORD,,
@@ -1,475 +0,0 @@
1
- # -*- coding:utf-8 -*-
2
- import pymysql
3
- import logging
4
- from typing import List, Optional, Dict
5
- import time
6
- import re
7
- import os
8
- import hashlib
9
- from dbutils.pooled_db import PooledDB
10
- from mdbq.log import spider_logging
11
- from mdbq.config import config
12
- import threading
13
- import queue
14
-
15
- dir_path = os.path.expanduser("~")
16
- config_file = os.path.join(dir_path, 'spd.txt')
17
- my_cont = config.read_config(config_file)
18
- username, password, port = my_cont['username'], my_cont['password'], my_cont['port']
19
- host = '127.0.0.1'
20
- logger = spider_logging.setup_logging(reMoveOldHandler=True, filename='optimize.log')
21
-
22
-
23
- class MySQLDeduplicator:
24
-
25
- def __init__(self, host: str, username: str, password: str, port: int = 3306):
26
- self.pool = PooledDB(
27
- creator=pymysql,
28
- maxconnections=10, # 最大连接数
29
- mincached=2, # 初始化空闲连接数
30
- maxcached=5, # 空闲连接最大缓存数
31
- blocking=True,
32
- host=host,
33
- port=int(port),
34
- user=username,
35
- password=password,
36
- ping=1,
37
- charset='utf8mb4',
38
- cursorclass=pymysql.cursors.DictCursor
39
- )
40
- self.set_typ = {
41
- '日期': 'date',
42
- '更新时间': 'timestamp',
43
- }
44
- self.tables_to_reset = queue.Queue() # 线程安全队列
45
- self.delay_time = 120 # 延迟重置自增 id
46
- self.lock = threading.Lock() # 用于关键操作同步
47
-
48
- def get_table_in_databases(self, db_list=None, reset_id=False):
49
- """
50
- reset_id: 是否重置自增 id
51
- """
52
- if not db_list:
53
- return
54
- connection = self.get_connection()
55
- res = []
56
- for db_name in db_list:
57
- try:
58
- with connection.cursor() as cursor:
59
- cursor.execute(f"USE `{db_name}`")
60
- cursor.execute("SHOW TABLES")
61
- tables = cursor.fetchall()
62
- for index, item in enumerate(tables):
63
- res.append(
64
- {
65
- 'db_name': db_name,
66
- 'table_name': item.get(f'Tables_in_{db_name}', ''),
67
- 'reset_id': reset_id,
68
- }
69
- )
70
- except:
71
- pass
72
- connection.close()
73
- return res
74
-
75
- def deduplicate(
76
- self,
77
- tables_list: List[Dict],
78
- order_column: str = "更新时间",
79
- order_direction: str = "DESC",
80
- batch_size: int = 10000,
81
- id_column: str = "id",
82
- recent_months: Optional[int] = None
83
- ) -> bool:
84
- """
85
- 执行多表去重操作
86
- :param tables_list: 目标表配置列表,每个元素为字典,包含db_name, table_name, unique_keys(可选), reset_id(可选)
87
- :param order_column: 排序字段
88
- :param order_direction: 排序方向 (ASC/DESC)
89
- :param batch_size: 批量删除批次大小
90
- :param id_column: 自增列名称
91
- :return: 是否全部成功
92
- """
93
- if recent_months is not None and (not isinstance(recent_months, int) or recent_months < 1):
94
- logger.error("recent_months必须为None或正整数")
95
- return False
96
- for table_config in tables_list:
97
- config = {
98
- 'order_column': order_column,
99
- 'order_direction': order_direction,
100
- 'batch_size': batch_size,
101
- 'id_column': id_column,
102
- 'reset_id': table_config.get('reset_id', False), # 处理默认值
103
- 'unique_keys': table_config.get('unique_keys', None),
104
- 'recent_months': recent_months,
105
- }
106
- config.update(table_config)
107
- self._deduplicate_single_table(**config)
108
-
109
- def _deduplicate_single_table(
110
- self,
111
- db_name: str,
112
- table_name: str,
113
- unique_keys: Optional[List[str]],
114
- order_column: str,
115
- order_direction: str,
116
- batch_size: int,
117
- reset_id: bool,
118
- id_column: str,
119
- recent_months: Optional[int] = None
120
- ):
121
- """单表去重逻辑"""
122
-
123
- # 获取数据库连接并检查有效性
124
- connection = self.get_connection(db_name=db_name)
125
- if not connection:
126
- logger.error(f"连接数据库失败: {db_name}")
127
- return False
128
-
129
- temp_suffix = hashlib.md5(f"{table_name}{time.time()}".encode()).hexdigest()[:8]
130
- temp_table = f"temp_{temp_suffix}"
131
-
132
- try:
133
- # 版本检查在check_db内部
134
- if not self.check_db(db_name, table_name):
135
- return False
136
-
137
- with connection.cursor() as cursor:
138
- # 主键重复检查
139
- try:
140
- cursor.execute(f"""
141
- SELECT COUNT(*) AS total,
142
- COUNT(DISTINCT `{id_column}`) AS distinct_count
143
- FROM `{table_name}`
144
- """)
145
- except pymysql.err.InternalError as e:
146
- if e.args[0] == pymysql.constants.ER.BAD_FIELD_ERROR:
147
- logger.warning(f"{db_name}/{table_name} 跳过主键检查(无{id_column}列)")
148
- else:
149
- raise
150
- else:
151
- res = cursor.fetchone()
152
- if res['total'] != res['distinct_count']:
153
- logger.error(f"{db_name}/{table_name} 主键重复: {id_column}")
154
- return False
155
-
156
- all_columns = self._get_table_columns(db_name, table_name)
157
- # 自动生成unique_keys逻辑
158
- if not unique_keys:
159
- exclude_set = {id_column.lower(), order_column.lower()}
160
-
161
- if not all_columns:
162
- logger.error(f"{db_name}/{table_name} 无法获取表列信息")
163
- return False
164
-
165
- # 排除id_column和order_column
166
- unique_keys = [
167
- col for col in all_columns
168
- if col.lower() not in exclude_set
169
- and col != id_column # 额外确保大小写兼容
170
- and col != order_column
171
- ]
172
- # 检查剩余列是否有效
173
- if not unique_keys:
174
- unique_keys = all_columns
175
- logger.warning(f"{db_name}/{table_name} 使用全列作为唯一键: {all_columns}")
176
- return False
177
- # logger.info(f"自动生成unique_keys: {unique_keys}")
178
- else:
179
- if not self._validate_columns(db_name, table_name, unique_keys):
180
- logger.error(f"{db_name}/{table_name} unique_keys中存在无效列名")
181
- return False
182
-
183
- # 动态生成临时表名
184
- partition_clause = ', '.join([f'`{col}`' for col in unique_keys])
185
-
186
- # 使用参数化查询创建临时表
187
- if self._validate_columns(db_name, table_name, [order_column]):
188
- order_clause = f"ORDER BY `{order_column}` {order_direction}" if order_column else ""
189
- else:
190
- order_clause = ''
191
-
192
- # 时间过滤
193
- where_clause = ""
194
- query_params = []
195
- date_column_exists = '日期' in all_columns
196
- if recent_months and recent_months > 0 and date_column_exists:
197
- where_clause = "WHERE `日期` >= DATE_SUB(CURDATE(), INTERVAL %s MONTH)"
198
- query_params.append(recent_months)
199
- elif recent_months and not date_column_exists:
200
- logger.warning(f"{db_name}/{table_name} 忽略recent_months参数(无日期列)")
201
-
202
- create_temp_sql = f"""
203
- CREATE TEMPORARY TABLE `{temp_table}` AS
204
- SELECT tmp_id FROM (
205
- SELECT `{id_column}` AS tmp_id,
206
- ROW_NUMBER() OVER (
207
- PARTITION BY {partition_clause or '1'}
208
- {order_clause}
209
- ) AS row_num
210
- FROM `{table_name}`
211
- {where_clause}
212
- ) t WHERE row_num > 1;
213
- """
214
- cursor.execute(create_temp_sql, query_params)
215
-
216
- logger.info(f'{db_name}/{table_name} 执行排重任务')
217
- # 批量删除优化
218
- iteration = 0
219
- total_deleted = 0
220
- while True and iteration < 10000:
221
- iteration += 1
222
- # 获取并删除临时表中的数据,避免重复处理
223
- cursor.execute(f"""
224
- SELECT tmp_id
225
- FROM `{temp_table}`
226
- LIMIT %s
227
- FOR UPDATE;
228
- """, (batch_size,))
229
- batch = cursor.fetchall()
230
- if not batch:
231
- break
232
- ids = [str(row['tmp_id']) for row in batch]
233
- placeholder = ','.join(['%s'] * len(ids))
234
-
235
- if ids:
236
- try:
237
- # 删除主表数据
238
- cursor.execute(f"DELETE FROM `{table_name}` WHERE `{id_column}` IN ({placeholder})", ids)
239
-
240
- # 删除临时表中已处理的记录
241
- cursor.execute(f"DELETE FROM `{temp_table}` WHERE tmp_id IN ({placeholder})", ids)
242
- except pymysql.err.InternalError as e:
243
- if e.args[0] == pymysql.constants.ER.BAD_FIELD_ERROR:
244
- logger.error(f"{db_name}/{table_name} 无法通过 {id_column} 删除记录,请检查列存在性")
245
- return False
246
- raise
247
-
248
- total_deleted += cursor.rowcount
249
- connection.commit()
250
- logger.info(f"{db_name}/{table_name} 执行去重, 删除记录数: {total_deleted}")
251
-
252
- if total_deleted > 0:
253
- logger.info(f"{db_name}/{table_name} 删除记录数总计: {total_deleted}")
254
-
255
- # 线程安全操作队列
256
- if reset_id:
257
- if not self._validate_columns(db_name, table_name, [id_column]):
258
- return True
259
-
260
- with self.lock:
261
- self.tables_to_reset.put((db_name, table_name, id_column))
262
- logger.info(f"{db_name}/{table_name} -> {self.delay_time}秒后重置自增id")
263
- threading.Timer(self.delay_time, self.delayed_reset_auto_increment).start()
264
-
265
- return True
266
- except Exception as e:
267
- logger.error(f"{db_name}/{table_name} 去重操作异常: {e}", exc_info=True)
268
- connection.rollback()
269
- return False
270
- finally:
271
- with connection.cursor() as cursor:
272
- cursor.execute(f"DROP TEMPORARY TABLE IF EXISTS `{temp_table}`")
273
- connection.close()
274
-
275
- def _get_table_columns(self, db_name: str, table_name: str) -> List[str]:
276
- """获取表的列"""
277
- try:
278
- connection = self.get_connection(db_name=db_name)
279
- with connection.cursor() as cursor:
280
- cursor.execute(f"SHOW COLUMNS FROM `{table_name}`")
281
- return [row["Field"] for row in cursor.fetchall()]
282
- except pymysql.Error as e:
283
- logging.error(f"{db_name}/{table_name} 获取列失败: {e}")
284
- return []
285
-
286
- def check_db(self, db_name: str, table_name: str) -> bool:
287
- """数据库检查"""
288
- try:
289
- with self.get_connection() as conn:
290
- with conn.cursor() as cursor:
291
- # 获取MySQL版本
292
- version = self._check_mysql_version(cursor)
293
- collation = 'utf8mb4_0900_ai_ci' if version >= 8.0 else 'utf8mb4_general_ci'
294
-
295
- # 创建数据库
296
- cursor.execute(f"""
297
- CREATE DATABASE IF NOT EXISTS `{db_name}`
298
- CHARACTER SET utf8mb4 COLLATE {collation}
299
- """)
300
- conn.commit()
301
-
302
- # 切换数据库
303
- cursor.execute(f"USE `{db_name}`")
304
-
305
- # 检查表是否存在
306
- if not self._table_exists(cursor, table_name):
307
- self._create_table(cursor, table_name)
308
- conn.commit()
309
- return True
310
- except Exception as e:
311
- logger.error(f"{db_name}/{table_name} 数据库检查失败: {e}")
312
- return False
313
-
314
- def get_connection(self, db_name=None):
315
- """从连接池获取连接"""
316
- for _ in range(10):
317
- try:
318
- if db_name:
319
- connection = self.pool.connection()
320
- with connection.cursor() as cursor:
321
- cursor.execute(f'use {db_name};')
322
- return connection
323
-
324
- return self.pool.connection()
325
- except pymysql.Error as e:
326
- logger.error(f"{db_name} 获取连接失败: {e}, 30秒后重试...")
327
- time.sleep(30)
328
- logger.error(f"{host}: {port} 数据库连接失败,已达最大重试次数")
329
- return None
330
-
331
- def _validate_identifier(self, name: str) -> bool:
332
- """更严格的对象名验证(符合MySQL规范)"""
333
- return re.match(r'^[\w$]+$', name) and len(name) <= 64
334
-
335
- def _validate_columns(self, db_name: str, table_name: str, columns: List[str]) -> bool:
336
- """验证列是否存在"""
337
- if not all(self._validate_identifier(col) for col in columns):
338
- return False
339
- try:
340
- connection = self.get_connection(db_name=db_name)
341
- with connection.cursor() as cursor:
342
- cursor.execute(f"SHOW COLUMNS FROM `{table_name}`")
343
- existing_columns = {col['Field'] for col in cursor.fetchall()}
344
- return all(col in existing_columns for col in columns)
345
- except pymysql.Error as e:
346
- logging.error(f"{db_name}/{table_name} 列验证失败: {e}")
347
- return False
348
-
349
- def _check_mysql_version(self, cursor) -> float:
350
- """通过传入游标检查版本"""
351
- cursor.execute("SELECT VERSION()")
352
- return float(cursor.fetchone()['VERSION()'][:3])
353
-
354
- def _table_exists(self, cursor, table_name: str) -> bool:
355
- cursor.execute("SHOW TABLES LIKE %s", (table_name,))
356
- return cursor.fetchone() is not None
357
-
358
- def _create_table(self, cursor, table_name: str):
359
- """安全建表逻辑"""
360
- columns = ["`id` INT AUTO_INCREMENT PRIMARY KEY"]
361
- for cn, ct in self.set_typ.items():
362
- col_def = f"`{cn}` {ct.upper()} NOT NULL DEFAULT "
363
- if 'INT' in ct:
364
- col_def += '0'
365
- elif 'TIMESTAMP' in ct:
366
- col_def += 'CURRENT_TIMESTAMP'
367
- else:
368
- col_def += "''"
369
- columns.append(col_def)
370
- cursor.execute(f"""
371
- CREATE TABLE `{table_name}` (
372
- {', '.join(columns)}
373
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
374
- """)
375
-
376
- def delayed_reset_auto_increment(self):
377
- """线程安全的自增ID重置"""
378
- while not self.tables_to_reset.empty():
379
- try:
380
- item = self.tables_to_reset.get_nowait()
381
- self._safe_reset_auto_increment(*item)
382
- except queue.Empty:
383
- break
384
-
385
- def _safe_reset_auto_increment(self, db_name: str, table_name: str, id_column: str):
386
- """安全重置自增ID"""
387
- with self.get_connection(db_name) as conn:
388
- try:
389
- with conn.cursor() as cursor:
390
- cursor.execute("START TRANSACTION")
391
- temp_table = f"reset_{hashlib.md5(table_name.encode()).hexdigest()[:8]}"
392
- backup_table = f"{table_name}_backup_{int(time.time())}"
393
- cursor.execute(f"CREATE TABLE `{temp_table}` LIKE `{table_name}`")
394
- cursor.execute(f"ALTER TABLE `{temp_table}` MODIFY COLUMN `{id_column}` INT NOT NULL")
395
- columns = self._get_table_columns(db_name, table_name)
396
- if id_column not in columns:
397
- logger.error(f"列 {id_column} 不存在于表 {table_name}")
398
- return False
399
- columns.remove(id_column)
400
- columns_str = ', '.join([f'`{col}`' for col in columns])
401
- insert_sql = f"""
402
- INSERT INTO `{temp_table}` (`{id_column}`, {columns_str})
403
- SELECT ROW_NUMBER() OVER (ORDER BY `{id_column}`), {columns_str}
404
- FROM `{table_name}` ORDER BY `{id_column}`
405
- """
406
- cursor.execute(insert_sql)
407
- cursor.execute(f"RENAME TABLE `{table_name}` TO `{backup_table}`, `{temp_table}` TO `{table_name}`")
408
- cursor.execute(f"ALTER TABLE `{table_name}` MODIFY COLUMN `{id_column}` INT AUTO_INCREMENT")
409
- cursor.execute(f"SELECT MAX(`{id_column}`) + 1 AS next_id FROM `{table_name}`")
410
- next_id = cursor.fetchone()['next_id'] or 1
411
- cursor.execute(f"ALTER TABLE `{table_name}` AUTO_INCREMENT = {next_id}")
412
- cursor.execute(f"DROP TABLE IF EXISTS `{backup_table}`")
413
- cursor.execute(f"DROP TEMPORARY TABLE IF EXISTS `{temp_table}`")
414
- cursor.execute("COMMIT")
415
- logger.info(f'{db_name}/{table_name} 已重置自增id')
416
- except Exception as e:
417
- logger.error(f"{db_name}/{table_name} 重置自增id失败: {e}")
418
- cursor.execute("ROLLBACK")
419
- return False
420
- finally:
421
- conn.close()
422
-
423
-
424
- def main():
425
- op = MySQLDeduplicator(
426
- host=host,
427
- username=username,
428
- password=password,
429
- port=port
430
- )
431
- op.delay_time = 600
432
- # tables_list = [
433
- # {
434
- # 'db_name': "测试库",
435
- # 'table_name': "测试库2",
436
- # 'reset_id': True, # 可选, 默认 False
437
- # # 'unique_keys': ["日期", "店铺名称", "商品id"]
438
- # }
439
- # ]
440
- db_list = [
441
- "京东数据3",
442
- "属性设置3",
443
- "推广数据2",
444
- "推广数据_圣积天猫店",
445
- "推广数据_淘宝店",
446
- "推广数据_奥莱店",
447
- "爱库存2",
448
- "生意参谋3",
449
- "生意经3",
450
- "达摩盘3",
451
- '人群画像2',
452
- '商品人群画像2',
453
- '市场数据3',
454
- # '数据银行2'
455
- # '回传数据',
456
- # '大模型库',
457
- '安全组',
458
- # '视频数据',
459
- # '聚合数据',
460
- '数据引擎2'
461
- ]
462
- tables_list = op.get_table_in_databases(db_list=db_list, reset_id=False)
463
- op.deduplicate(
464
- order_column = "更新时间",
465
- order_direction = "DESC",
466
- batch_size = 1000,
467
- id_column = "id",
468
- tables_list=tables_list,
469
- recent_months=3,
470
- )
471
- logger.info(f'全部任务完成')
472
-
473
-
474
- if __name__ == "__main__":
475
- main()
File without changes