#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 重名表「丢弃份是不是保留份的子集」精确校验 —— 六库合一的最后一道数据闸门。 ## 为什么需要它 [`merge-conflict-plan.md`](../merge-conflict-plan.md) §0 列的**必须逐一确认的例外**有两条, 其中第 2 条是: > 两侧行数都非 0 且差异大时,不能假定"主份已包含副本全部行"—— > 要抽样比对,确认没有只存在于副本的历史数据。 抽样比对只能给出"抽样命中"这种**概率性**结论(§2.1 里 `park_in_out_record` 那次是抽 300 个回查、命中 300/300)。本工具把这件事做成**精确**的: 所有六个库都在**同一台实例**上(见 [`legacy-db-actual.md`](../legacy-db-actual.md) §1), 所以可以直接发跨库 `LEFT JOIN`,一次问清"丢弃份里有多少行的主键在保留份里**找不到**"。 那个数就是**合库后会真正丢掉的行数**。0 ⇒ 这份可以安全丢弃;非 0 ⇒ 必须人工处置 (取并集 / 查清调用方 / 单独导出)。 ## ⚠️ 只读且只看键列 本脚本**只发 SELECT**,且只 SELECT **主键列**(外加显式配置的键列)—— 绝不 `SELECT *`。这一条是刻意的:清单里有些表(如 `td_b_wx_config`) 装着微信支付配置,通配查询会把密钥打进终端和日志。只投影键列, 就**不可能**有任何敏感列值出现在输出里。 ## ⚠️ 键选错 = 假警报(本脚本踩过的坑) `--key` 用来把判定从「主键」换成「业务键」,**副本换过 id 空间时,只有业务键才说真话**。 但反过来也成立:**业务键选错会造出成片假独有**。已修掉的两例: - `t_vip_card_cust_info`:按主键 `id` 比 → 27 行"独有";按 `card_no` 比 → **0**。 同一批卡换了 id,不是丢了数据 - `td_b_wx_config`:按 `org_id` 比 → 18 行"独有";按 `appid` 比 → **3**。 老库的 `org_id` **大量为 NULL**,而 `NULL = NULL` 在 SQL 里不成立, 于是 join 必然落空 —— 那 18 行是**连接失败**,不是数据缺失。 这张表的真实查询键是 `appid`:`WxPayApiConfigKit` 就是按 appId 找配置的 (找不到直接抛 `IllegalStateException`) > 规律:**键列里只要可能有 NULL,用它做 join 就一定会误报**。 > 选业务键前先问一句「这个列在老数据里是否普遍为空」。 用法: python3 -I sql/tools/check_subset.py --env 私密/legacy-ro.env python3 -I sql/tools/check_subset.py --env 私密/legacy-ro.env --only td_c_cust_person python3 -I sql/tools/check_subset.py --env 私密/legacy-ro.env --sample 20 退出码:0 = 所有待丢弃份都已被证明是子集(或已被人工判定);1 = 仍有需人工处置的。 """ import argparse import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from legacy_db import connect, db_names, load_env # noqa: E402 # --------------------------------------------------------------------------- # 待校验清单:唯一的事实来源 # # 每项 = (表, 保留侧模块, 丢弃侧模块, 显式键列 or None 表示自动取主键, 备注) # 模块名对应 env 文件里的 LEGACY_DB_<模块>;注意历史填写里 # PAY → zt_order(pay 域活库 + 2018 订单归档) # ORDER → zt_order_n(order 域活库) # —— 库名与业务域是错位的,所以这里必须写「模块」而不是直接写库名, # 由 db_names() 解析,避免把错位的名字抄错第二次。 # # ⚠️ 第 4 列**不要留 None 就完事**:副本换过 id 空间时主键会满屏假独有。 # 已固化三处业务键(见文件头「键选错 = 假警报」): # t_vip_card_cust_info → card_no sys_org → org_code # td_b_wx_config → appid (org_id 为 NULL 会造出 18 行假独有) # --------------------------------------------------------------------------- CHECKS = [ # ---- §2.1 「主份是超集且活跃」5 组:本工具要给每份副本一个精确的"可丢"证明 ---- ("parking_lot", "PARK", "BILL", "pl_no", "zt_bill 1603 行(无更新)"), ("parking_lot", "PARK", "PAY", "pl_no", "zt_order 1465 行(无更新)"), ("parking_lot", "PARK", "ORDER", "pl_no", "zt_order_n 1979 行(无更新)"), ("park_in_out_record", "PARK", "PAY", "order_id", "§2.1 已抽 300/300 命中,此处改做精确全量"), ("t_vip_card_cust_info", "BILL", "PAY", "card_no", "zt_order 1892 行陈旧;按 id 会假报 27 行,按 card_no 为 0"), ("tp_p_berths", "PARK", "ORDER", None, "zt_order_n 20554 行;按 id 有 31 行独有,但 1681 行同 id 已被改写"), ("tp_e_employee_charge", "PARK", "ORDER", None, "zt_order_n 940 行"), # ---- §2.2 🔴「动手前必须查调用方」4 组 ---- ("sys_org", "SYS", "PARK", "org_code", "zt_park 20 行(残缺副本)"), ("sys_org", "SYS", "ORDER", "org_code", "zt_order_n 20 行(残缺副本)"), ("sys_org", "SYS", "PAY", "org_code", "zt_order 0 行"), ("td_c_cust_person", "PAY", "ORDER", "cust_id", "方向相反:活的在 zt_order(超集)"), ("td_c_coupon_person", "PAY", "ORDER", None, "zt_order_n 5720 行待定性"), ("td_b_settlement_record", "ORDER", "PAY", None, "§2.2:zt_order 90 行建议并集"), # ---- §1 「结构完全一致」7 组:结构同,但数据来源仍要定 ---- ("income_temp", "ORDER", "PARK", None, "353/353 行,两侧同数"), ("month_dchz", "ORDER", "PARK", "time,pl_no", "199/199 行,两侧同数;两侧都无主键"), ("table_15_free", "ORDER", "PARK", "dateStr,pl_no", "344/342 行,差 2;两侧都无主键"), ("td_b_settlement", "ORDER", "PAY", None, "两侧皆空"), ("td_b_settlement_org", "ORDER", "PAY", None, "zt_order 0 行"), ("td_b_settlement_serial", "ORDER", "PAY", None, "zt_order 0 行"), ("td_b_settlement_share_record", "ORDER", "PAY", None, "20/33 行,两侧都有数据"), ("td_p_parking_share_order_temp", "ORDER", "PAY", None, "临时表"), # ---- §2.3 🟡 无活跃方 3 组 ---- ("tb_b_park_income_day", "ORDER", "PAY", None, "zt_order 1796 行待定性"), ("td_b_wx_config", "ORDER", "PAY", "appid", "两侧 27/22 行且都无更新时间;⚠️ 本表含支付配置,仅比对 appid,不比对内容"), ] def pk_columns(cur, db, table): """主键列(按索引顺序)。没有主键返回 []。""" cur.execute( "SELECT COLUMN_NAME FROM information_schema.STATISTICS " "WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s AND INDEX_NAME='PRIMARY' " "ORDER BY SEQ_IN_INDEX", (db, table)) return [r[0] for r in cur.fetchall()] def one(cur, db, table): cur.execute("SELECT COUNT(*) FROM `%s`.`%s`" % (db, table)) return cur.fetchone()[0] def check(cur, table, keep_db, drop_db, key, sample): """返回 dict 结论。只投影键列,不做 SELECT *。""" # 键列:显式给的优先,否则自动取「丢弃侧」的主键(要丢的那份才是被探测的对象) if key: cols = [c.strip() for c in key.split(",")] else: cols = pk_columns(cur, drop_db, table) res = {"table": table, "keep_db": keep_db, "drop_db": drop_db, "key": cols, "keep_n": None, "drop_n": None, "drop_only": None, "samples": [], "note": ""} if not cols: res["note"] = "两侧都没有主键,且未配置键列 → 无法自动判定" return res keep_pk = pk_columns(cur, keep_db, table) missing_in_keep = [c for c in cols if c not in keep_pk] if missing_in_keep: # 键不是保留侧主键时的两点说明: # ① 结论强度下降:只能说「这个键值在保留侧不存在」,不能说「这一行不存在」; # ② 若该键在**丢弃侧**也不唯一,行数会把「同一个业务对象」重复计数, # 所以下面同时给出行数与**去重后的键数**,两者不等就说明键不唯一。 res["note"] = ("键列 %s 在保留侧不是主键 → 结论是「键值不存在」而非「行不存在」" % ",".join(missing_in_keep)) on = " AND ".join("k.`%s` = d.`%s`" % (c, c) for c in cols) res["keep_n"] = one(cur, keep_db, table) res["drop_n"] = one(cur, drop_db, table) distinct = ("COUNT(DISTINCT d.`%s`)" % cols[0] if len(cols) == 1 else "COUNT(DISTINCT CONCAT_WS('\\x1f', %s))" % ", ".join("d.`%s`" % c for c in cols)) cur.execute( "SELECT COUNT(*), %s FROM `%s`.`%s` d LEFT JOIN `%s`.`%s` k ON %s " "WHERE k.`%s` IS NULL" % (distinct, drop_db, table, keep_db, table, on, cols[0])) res["drop_only"], res["drop_only_distinct"] = cur.fetchone() if res["drop_only"]: cur.execute( "SELECT %s FROM `%s`.`%s` d LEFT JOIN `%s`.`%s` k ON %s " "WHERE k.`%s` IS NULL LIMIT %d" % (", ".join("d.`%s`" % c for c in cols), drop_db, table, keep_db, table, on, cols[0], sample)) res["samples"] = cur.fetchall() return res def main(): ap = argparse.ArgumentParser() ap.add_argument("--env", default=os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "..", "私密", "legacy-ro.env")) ap.add_argument("--only", action="append", help="只跑这几张表(可重复;默认全部)") ap.add_argument("--sample", type=int, default=10, help="发现独有行时最多列出几条键(默认 10)") ap.add_argument("--timeout", type=int, default=600, help="单条 SQL 的读超时秒数(默认 600;大表逐行探测需要)") ap.add_argument("--key", action="append", metavar="表=列1,列2", help="覆盖某张表的键(按表名匹配,可重复)。" "用途:按主键判定「行不存在」后,再按**业务键**判定一次 —— " "副本若只是换了 id 空间,用业务键才不会误判成「丢了数据」") a = ap.parse_args() cfg = load_env(a.env) dbs = db_names(cfg) # --key 的覆盖:只按表名匹配,允许同一张表在不同保留/丢弃组合上都换键 over = {} for kv in (a.key or []): t, _, cols = kv.partition("=") if not cols: print("--key 格式应为 表=列[,列]", file=sys.stderr) return 2 over[t.strip()] = cols.strip() want = [c for c in CHECKS if not a.only or c[0] in a.only] if over: # 显式给了键的表,即使没在 CHECKS 的 --only 里也要跑,避免"改了键却没跑" seen = {c[0] for c in want} for t in over: if t not in seen: want += [c for c in CHECKS if c[0] == t] if not want: print("没有匹配的检查项", file=sys.stderr) return 2 conn = connect(cfg, read_timeout=a.timeout) need_manual = [] try: with conn.cursor() as cur: print("=" * 78) print("重名表「丢弃份 ⊂ 保留份」精确校验(跨库 LEFT JOIN,只看键列)") print("=" * 78) for table, keep_mod, drop_mod, key, note in want: kd, dd = dbs[keep_mod], dbs[drop_mod] r = check(cur, table, kd, dd, over.get(table, key), a.sample) head = "%s ← 保留 %s(%s) / 丢弃 %s(%s)" % (table, keep_mod, kd, drop_mod, dd) print("\n" + head) print(" 备注: %s" % note) if r["keep_n"] is None: print(" ✋ %s" % r["note"]) need_manual.append(head) continue print(" 保留侧 %d 行 / 丢弃侧 %d 行 / 键 %s" % (r["keep_n"], r["drop_n"], "+".join(r["key"]))) if r["note"]: print(" ⚠️ %s" % r["note"]) if r["drop_only"] == 0: print(" ✅ 丢弃侧**全部**落在保留侧内 → 可安全丢弃(丢 0 行)") else: uniq = "" if r["drop_only_distinct"] != r["drop_only"]: uniq = ("(去重后 %d 个键 —— 键在丢弃侧不唯一," "上面是行数)" % r["drop_only_distinct"]) print(" 🔴 丢弃侧有 **%d 行**在保留侧找不到%s → 丢掉会真的丢数据" % (r["drop_only"], uniq)) if r["samples"]: print(" 独有键样本: %s" % ", ".join(str(s[0]) if len(s) == 1 else str(s) for s in r["samples"])) need_manual.append(head) finally: conn.close() print("\n" + "=" * 78) if need_manual: print("需人工处置 %d 组:" % len(need_manual)) for h in need_manual: print(" · %s" % h) print("\n处置方向:独有行取并集(`INSERT ... SELECT` 补进保留份,再丢弃副本);" "\n 无主键无法判定的,先给它定一个业务键再跑一次。") else: print("✅ 全部待丢弃份都已被证明是保留份的子集 —— 可安全合并。") print("=" * 78) return 1 if need_manual else 0 if __name__ == "__main__": sys.exit(main())