简介:这份压缩包面向金融数据从业者、风控开发者和数据库学习者,围绕银行卡BIN号与归属行识别提供可直接落地的数据底稿。包内共7个文件,以5个xls表格和2个sql脚本为主,压缩后约1.4MB;表格覆盖2020年4月25日版农民工卡、跨行转账卡、非标卡、单位结算卡等分类卡表,SQL脚本便于在MySQL与PostgreSQL中建表、导入和查询。已有861人学习下载,说明其在银行卡归属行核对、交易验证与反欺诈等场景中具有一定参考价值。读者可据此整理BIN号、发卡行、卡类型等字段,在Excel中筛选分析,或导入关系型数据库做批量匹配、分布统计与数据更新维护,从而快速搭建银行卡BIN号管理与应用的基础环境。
1. 银行卡 BIN 号为什么要落成一张可查的表
一张卡号的前 8 位,决定了这笔交易走哪条通道、按什么费率结算、要不要进风控名单。很多团队第一次维护银行卡 BIN 号,是拿运营给的一份 Excel,手工往代码里贴常量;等号段涨到几万条、每个季度更新一次、MySQL 里还躺着一张三年前建的表没下线,问题就全冒出来了——同一张卡在路由侧判成借记、在清分侧判成贷记,对账差一天。下面讲的是把银行卡 BIN 号从 Excel 落到 MySQL 与 PostgreSQL、并且让它能持续更新的完整路径:号段怎么归一、索引怎么建、最长前缀匹配怎么写成一条不翻车的 SQL、版本切换怎么做灰度。做支付路由、收单风控、账务清分的后端同学可以直接照着走。
2. 把 Excel 里的 BIN 号段读干净:前导零、科学计数法与三种脏表头
清洗这一步的返工率远高于后面建表。原因不复杂:BIN 号段天生是「有前导零的数字串」,而 Excel 天生喜欢把数字串变成数字。
2.1 BIN 号段在 Excel 里的三种真实形态
运营导出的表,字段名五花八门,但数据本身基本落在三类里。第一类是纯前缀:622202、62220212,长度 6 到 8 位不等。第二类是区间:62220200到62220299,一行表示一段。第三类是带校验位的完整卡号样例:6222021234567890,只用来验证前缀判得对不对,不能入库。
三种形态混在一张表里是常态。所以第一件事不是写 import,而是判断每一行属于哪种,把区间形态拆成 start/end,把卡号样例单独抽到校验集里。常见的字段映射大概是这个样子:
| Excel 列名(常见变体) | 含义 | 归一化后落库字段 | 注意 |
|---|---|---|---|
| BIN / 卡BIN / bin_no / 卡号前6位 | 号段前缀 | prefix | 必须是字符串,不能是数字 |
| 卡BIN长度 / 位数 | 前缀长度 | bin_len | 用length(prefix)反推更可靠 |
| 起始号 / start / 号段起 | 区间下界 | bin_start | 区间形态才有 |
| 结束号 / end / 号段止 | 区间上界 | bin_end | 区间形态才有 |
| 卡组织 / 卡品牌 | 卡组织标识 | card_brand | 取值要做枚举收敛 |
| 借贷标识 / 卡类型 | 借记/贷记/预付 | card_type | 中文值要统一 |
| 卡等级 / 产品级别 | 普卡/金卡/白金 | card_level | 可为空 |
| 生效日期 / 版本 | 数据版本 | version_id | 一定要有 |
提示:
bin_len不要直接信任 Excel 里的「位数」列。那张表被人手工改过之后,位数和实际前缀长度对不上的情况非常常见,用length(prefix)反推、再和原列做一次比对告警,比事后查数据错要省事得多。
2.2 用 openpyxl 探一遍单元格类型,别急着 read_excel
直接pd.read_excel是新手最容易踩的第一脚。pandas 拿到的是 openpyxl 已经解析过的值,一个622202如果单元格格式是「常规」,读出来就是整数622202,前导零在文件层面已经没了,脚本再聪明也救不回来。所以先用 openpyxl 把原始单元格类型和数据格式打出来看一眼。
# probe_bin_sheet.py —— 只做一件事:把前 30 行的单元格类型和值打出来 from openpyxl import load_workbook PATH = "bin_2024q3.xlsx" wb = load_workbook(PATH, read_only=True, data_only=True) ws = wb["BIN"] # sheet 名先写死,跑通了再改成遍历 wb.sheetnames for idx, row in enumerate(ws.iter_rows(min_row=1, max_row=30)): cells = [] for c in row[:5]: # 前 5 列基本够看 # data_type: 's' 字符串 / 'n' 数字 / 'f' 公式 / 'd' 日期 / 'e' 错误 cells.append(f"{c.coordinate}={c.value!r}[{c.data_type}]") print(idx, " | ".join(cells))这段代码的价值在于data_type那一位。如果前缀列全是[n],说明文件里存的就是数字,长度不足 6 位的号段(比如012345)已经被吃成12345——这时候唯一的解法是让上游把该列设成文本格式重新导出,任何清洗脚本都补不回来。如果看到[f],说明有公式,data_only=True拿的是缓存值,缓存过期就得让上游另存一份。跑完这一步,再去写正式读取逻辑,方向就不会错。
2.3 归一化脚本:前导零、科学计数法、全角空格一起处理
确认了单元格类型之后,正式读取仍然要走 openpyxl 而不是 pandas,理由是我们要拿到单元格的原始值再做判断。下面这段是被踩过很多次之后收敛出来的写法。
# normalize_bin.py import re from openpyxl import load_workbook BIN_SPACE = 8 # 统一归一到 8 位前缀空间 BIN_LEN_MIN, BIN_LEN_MAX = 6, 8 # 只接受 6/7/8 位号段 _WS = re.compile(r"[\s\u3000\-_]") # 半角空格、全角空格、连字符、下划线 _TRAIL = re.compile(r"\.0+$") def norm_prefix(v): """把单元格原始值变成一个纯数字字符串,失败返回 None。""" if v is None: return None if isinstance(v, float): # 6.22202e+15 这种科学计数法,先转 int 再转 str # 注意:只对 <= 2^53 的整数无损,BIN 最多 8 位,安全 v = str(int(v)) s = _WS.sub("", str(v)) s = _TRAIL.sub("", s) # 去掉 float 转 str 带出来的 ".0" s = s.lstrip("'") # Excel 里手工加的文本标记 if not s.isdigit(): return None if not (BIN_LEN_MIN <= len(s) <= BIN_LEN_MAX): return None return s def load_prefixes(path, sheet="BIN", prefix_col=0, min_row=2): wb = load_workbook(path, read_only=True, data_only=True) ws = wb[sheet] good, bad = [], [] for r, row in enumerate(ws.iter_rows(min_row=min_row, values_only=True), start=min_row): p = norm_prefix(row[prefix_col]) (good if p else bad).append(p if p else (r, row[prefix_col])) return good, badnorm_prefix里的顺序很关键:先处理 float,再做字符清洗,最后做长度校验。顺序反了会出现'622202'被isdigit判通过、但622202.0被判失败的漏读。BIN_LEN_MIN/MAX卡在 6 到 8 之间,是因为 9 位以上在绝大多数卡组织规范里已经不属于 BIN 范畴,混进来通常是运营把「卡号前 9 位统计维度」误当成号段填了。bad列表不要随手丢弃,落一个bin_reject_YYYYMMDD.tsv,出问题的时候这是唯一的后悔药。
2.4 把 6 位号段展开到 8 位前缀空间,并做重叠自检
数据库里如果混着 6 位、7 位、8 位三种长度的前缀,查询侧就得写三段逻辑,非常容易漏。更稳的做法是:在任何一位号段长度上,都把它展开成 8 位空间里的一个闭区间。6 位的622202就代表62220200到62220299;7 位的6222021代表62220210到62220219;8 位的62220212就是它自己。
# expand_bin.py BIN_SPACE = 8 def expand(prefix: str, space: int = BIN_SPACE): """把任意长度的前缀展开成 space 位空间下的闭区间 [start, end]。""" scale = 10 ** (space - len(prefix)) start = int(prefix) * scale return start, start + scale - 1 def check_overlap(rows): """同长度号段之间不允许重叠;父子号段重叠是正常的,跳过。""" rows = sorted(rows, key=lambda r: (r["bin_len"], r["bin_start"])) conflicts, prev = [], None for r in rows: if prev and prev["bin_len"] == r["bin_len"] and r["bin_start"] <= prev["bin_end"]: conflicts.append((prev["prefix"], r["prefix"])) prev = r return conflicts这里有个设计上必须讲清楚的点:归一化之后,父子号段一定是重叠的。622202展开成[62220200, 62220299],62220212展开成[62220212, 62220212],后者完全落在前者里面。这不是数据错误,这是最长前缀匹配的物理基础。所以后面建库的时候,绝对不能在区间上加排他约束,只能在(prefix, bin_len, version_id)上加唯一约束。check_overlap之所以按bin_len分组比较,就是这个道理——只抓同龄人打架,不抓父子兵。
3. MySQL 落库:BIN 区间表、联合索引与最长前缀匹配
MySQL 这一侧的核心矛盾不是存不下,是查得准不准、快不快。几万行的字典表,全表扫也就几十毫秒,问题在于高并发下每秒几千次查询,全表扫会直接把 buffer pool 冲干净。
3.1 为什么 LIKE '622202%' 一定出问题
最常见的写法是WHERE card_no LIKE CONCAT(prefix, '%'),看着直观,坑有三个。
第一,前缀列在左边就废了索引。LIKE能不能走索引,取决于模式串有没有前导通配符。card_no LIKE '622202%'这种写法模式串在右、列在左,索引是用不上的(或者只能用覆盖索引扫全表)。第二,它天然拿不到最长前缀。622202和62220212两行都能匹配6222021234567890,LIMIT 1返回哪个取决于物理行序,这就是玄学的来源。第三,变长前缀让优化器没法估算行数,执行计划会随数据量悄悄漂移,测试环境好好的,生产环境突然变慢。
正确的建模是把「前缀匹配」翻译成「数值区间命中」,用一次范围查询 + 按长度倒排拿最长匹配,索引才有意义。
3.2 表结构与那个决定命运的联合索引
-- schema_mysql.sql CREATE TABLE card_bin ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, prefix VARCHAR(8) NOT NULL COMMENT '原始号段,保留前导零', bin_len TINYINT UNSIGNED NOT NULL COMMENT '原始号段长度 6/7/8', bin_start BIGINT UNSIGNED NOT NULL COMMENT '归一化到8位空间的区间下界', bin_end BIGINT UNSIGNED NOT NULL COMMENT '归一化到8位空间的区间上界', card_brand VARCHAR(16) NOT NULL COMMENT '卡组织标识', card_type VARCHAR(16) NOT NULL COMMENT 'DEBIT/CREDIT/PREPAID', card_level VARCHAR(16) NULL COMMENT '卡等级,可空', country_code CHAR(2) NULL COMMENT '发卡国家二字码', version_id INT UNSIGNED NOT NULL COMMENT '数据版本号', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_prefix_len_ver (prefix, bin_len, version_id), KEY idx_lookup (version_id, bin_len, bin_start), KEY idx_range (version_id, bin_start, bin_len) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='银行卡BIN号段字典';三个索引各有分工,别乱删。uk_prefix_len_ver是幂等导入的保险,重复导入同一版本不会产生脏行。idx_lookup服务下面要讲的「按长度逐级回查」写法,等值条件version_id + bin_len + bin_start三列全命中,走的是 ref 或 eq_ref。idx_range服务区间扫描写法,version_id等值 +bin_start范围,能把扫描面压到很小。
注意:
bin_start/bin_end一定要用BIGINT UNSIGNED。8 位前缀最大99999999,INT UNSIGNED装得下,但如果你以后把BIN_SPACE调到 9 位或 10 位(有些卡组织确实有更长的 IIN),INT会静默截断。这种事不值得省 4 个字节。
3.3 从 TSV 到 MySQL:LOAD DATA 与 executemany 的差距
清洗脚本产出 TSV,导入走LOAD DATA LOCAL INFILE。八万行数据,executemany分批 1000 条大概要几十秒,LOAD DATA通常在一秒内,而且不会把 redo log 打满。
# 客户端侧需要开启 local_infile mysql --local-infile=1 -h 127.0.0.1 -u app_rw -p carddb-- load_bin_mysql.sql SET SESSION sql_log_bin = 0; -- 字典表导入不需要进 binlog,视主从架构决定 LOAD DATA LOCAL INFILE '/tmp/card_bin_2024q3.tsv' INTO TABLE card_bin CHARACTER SET utf8mb4 FIELDS TERMINATED BY '\t' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (prefix, bin_len, bin_start, bin_end, card_brand, card_type, card_level, country_code, version_id); SET SESSION sql_log_bin = 1;IGNORE 1 LINES跳过表头,但前提是清洗脚本真的写了表头,且顺序和列清单完全一致——顺序错位是这类导入最常见的事故,症状是所有卡组织的值都错了一列,看着像「数据源变了」,其实是列声明写的。OPTIONALLY ENCLOSED BY '"'是为了兼容卡等级里带逗号或中文括号的情况。
导入之后立刻做一次自检,而不是等到线上:
-- verify_mysql.sql -- 1) 行数对不对 SELECT version_id, bin_len, COUNT(*) AS cnt FROM card_bin WHERE version_id = 202403 GROUP BY version_id, bin_len ORDER BY bin_len; -- 2) 有没有父子号段覆盖反了(子区间跑出父区间) SELECT c.prefix AS child, p.prefix AS parent FROM card_bin c JOIN card_bin p ON p.version_id = c.version_id AND p.bin_len < c.bin_len AND p.bin_start <= c.bin_start AND p.bin_end >= c.bin_end WHERE c.version_id = 202403 AND c.bin_len = 8 LIMIT 20;第二条 SQL 查出空集才说明层级关系是对的。如果查出来一堆,通常意味着源表里某几行 6 位号段的起止被人手改过,展开之后盖不住子号段。
3.4 最长前缀匹配的两种 SQL 写法与 EXPLAIN 对照
写法 A,区间扫描加长度倒排,最直观:
-- q_mysql_range.sql SET @card8 = 62220212; -- 卡号前 8 位转成整数 SELECT prefix, bin_len, card_brand, card_type, card_level FROM card_bin WHERE version_id = 202403 AND bin_start <= @card8 AND bin_end >= @card8 ORDER BY bin_len DESC LIMIT 1;这条能跑,执行计划里type=range、key=idx_range,先用version_id + bin_start把候选集缩到极小,再回表过滤bin_end,最后对剩下的几行做 filesort。数据量几万行的时候几十微秒,完全够用。缺点是ORDER BY bin_len DESC那次排序虽然小,但在极高 QPS 下仍然是额外的开销。
写法 B,按长度逐级回查,把排序消掉:
-- q_mysql_exact.sql SET @card8 = 62220212; SELECT prefix, bin_len, card_brand, card_type, card_level FROM card_bin WHERE version_id = 202403 AND ( (bin_len = 8 AND bin_start = @card8) OR (bin_len = 7 AND bin_start = @card8 DIV 10 * 10) OR (bin_len = 6 AND bin_start = @card8 DIV 100 * 100) ) ORDER BY bin_len DESC LIMIT 1;三个分支全是等值条件,idx_lookup会被展开成三次索引定位再合并,没有任何范围扫描和排序。这条在压测里的表现比写法 A 稳定得多,也更适合交给 ORM 之外的裸 SQL 层。DIV是整数除法,不要写成/,否则会得到小数再隐式转换,白白多一次类型转换。
提示:
@card8必须是「卡号左补零到 8 位」之后转的整数。如果卡号不足 8 位(测试数据里很常见),LEFT(card_no, 8)会得到一个短串,CAST成整数之后语义就变了,会误命中长度 6 的短前缀。入口处做一次长度校验比事后排查便宜得多。
4. PostgreSQL 落库:int8range 加 GiST 做前缀命中
选 PostgreSQL 的理由只有一个:它原生支持区间类型和区间索引,把「号段包含某前缀」这件事直接交给索引去做,不用在 SQL 里手写start <= x AND end >= x。
4.1 int8range 建模:三条约束守住数据质量
-- schema_pg.sql CREATE EXTENSION IF NOT EXISTS btree_gist; CREATE TABLE card_bin ( id bigserial PRIMARY KEY, prefix varchar(8) NOT NULL, bin_len smallint NOT NULL, bin_range int8range NOT NULL, card_brand text NOT NULL, card_type text NOT NULL, card_level text, country_code char(2), version_id integer NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT ck_prefix_digit CHECK (prefix ~ '^[0-9]+$'), CONSTRAINT ck_prefix_len CHECK (bin_len BETWEEN 6 AND 8 AND length(prefix) = bin_len), CONSTRAINT ck_range_bounds CHECK (bin_range = int8range(lower(bin_range), upper(bin_range), '[]')), CONSTRAINT uk_prefix_len_ver UNIQUE (prefix, bin_len, version_id) ); CREATE INDEX idx_card_bin_range_gist ON card_bin USING gist (bin_range); CREATE INDEX idx_card_bin_ver_len ON card_bin (version_id, bin_len);三条约束各自防一类事故。ck_prefix_digit防的是清洗脚本漏掉的全角数字和带字母的伪号段——~ '^[0-9]+$'在 PG 里对全角字符是不通过的,正好。ck_prefix_len把「位数」和「实际长度」绑死,导入时对不上会直接报错而不是静默入库。ck_range_bounds强制区间是闭区间[],这一条最容易被忽略:int8range(100, 200)默认是[100,200),上界不含,62220299这一行就永远查不到,症状是「每段号段的最后一位总判不出来」,非常隐蔽。
这里故意没有加区间排他约束(EXCLUDE USING gist)。原因和第 2 章说的一样:归一化之后父子区间必然重叠,加了排他约束会导致父号段根本插不进去。唯一性只能落在(prefix, bin_len, version_id)上。
4.2 COPY 进临时表,再在库内归一化
PostgreSQL 的COPY比 MySQL 的LOAD DATA更严格也更省心,但它不接受表达式,所以走两段式:先原样灌进临时表,再用一条INSERT ... SELECT做归一化。好处是归一化公式只有一份,写在 SQL 里,谁都能审。
-- load_bin_pg.sql CREATE TEMP TABLE card_bin_stg ( prefix varchar(8), bin_len smallint, card_brand text, card_type text, card_level text, country_code char(2), version_id integer ) ON COMMIT DROP; \copy card_bin_stg (prefix, bin_len, card_brand, card_type, card_level, country_code, version_id) FROM '/tmp/card_bin_2024q3.tsv' WITH (FORMAT csv, DELIMITER E'\t', HEADER true) INSERT INTO card_bin (prefix, bin_len, bin_range, card_brand, card_type, card_level, country_code, version_id) SELECT prefix, bin_len, int8range( (prefix::bigint * (10::numeric ^ (8 - bin_len)))::bigint, (prefix::bigint * (10::numeric ^ (8 - bin_len)) + 10::numeric ^ (8 - bin_len) - 1)::bigint, '[]' ), card_brand, card_type, card_level, country_code, version_id FROM card_bin_stg ON CONFLICT (prefix, bin_len, version_id) DO NOTHING;10::numeric ^ (8 - bin_len)里的::numeric不能省。PG 的^对整数参数会走 numeric 重载返回 numeric,但如果你写成10 ^ (8 - bin_len)而bin_len是 smallint,某些版本下会走到 float8 重载,8 位数字在 float8 里虽然精确,但这个隐式转换不值得赌。显式::numeric之后再::bigint,结果确定。ON CONFLICT DO NOTHING让这份脚本可以重复执行,配合唯一约束就是幂等导入。
导入完成后同样要自检,用 PG 写起来比 MySQL 短:
-- verify_pg.sql SELECT bin_len, count(*) FROM card_bin WHERE version_id = 202403 GROUP BY bin_len ORDER BY bin_len; -- 有没有 8 位子区间没被任何 6 位父区间盖住(通常意味父号段缺失) SELECT c.prefix FROM card_bin c WHERE c.version_id = 202403 AND c.bin_len = 8 AND NOT EXISTS ( SELECT 1 FROM card_bin p WHERE p.version_id = c.version_id AND p.bin_len = 6 AND p.bin_range @> c.bin_range ) LIMIT 20;4.3 @> 查询与 GiST 索引的 EXPLAIN 实况
-- q_pg_contains.sql EXPLAIN (ANALYZE, BUFFERS) SELECT prefix, bin_len, card_brand, card_type, card_level FROM card_bin WHERE version_id = 202403 AND bin_range @> 62220212::bigint ORDER BY upper(bin_range) - lower(bin_range) ASC LIMIT 1;@>是「包含」操作符,bin_range @> 62220212读作「这个区间包含 62220212」。GiST 索引能直接服务它,计划里应该出现Index Scan using idx_card_bin_range_gist和Index Cond: (bin_range @> '62220212'::bigint)。
排序那行ORDER BY upper(bin_range) - lower(bin_range) ASC值得单独说。区间越窄,说明前缀越长、越具体,所以按区间宽度升序取第一条,等价于「最长前缀优先」。这个写法比ORDER BY bin_len DESC更本质——它不依赖bin_len这个冗余字段,即使将来号段长度规则变了(比如混进 9 位),逻辑依然成立。代价是upper - lower是个表达式,走不了索引排序,但那几行数据排一下可以忽略。
如果version_id的选择性很高、且同时存在多个版本,把 GiST 索引建成复合的更划算:
CREATE INDEX idx_card_bin_ver_range_gist ON card_bin USING gist (version_id, bin_range);这依赖前面CREATE EXTENSION btree_gist——GiST 本身不认识整数等值,是 btree_gist 把=操作符接进去的。漏建扩展会直接报data type integer has no default operator class for access method "gist",这句话记住,能省半小时。
4.4 MySQL 与 PostgreSQL 的选型对照
两套方案都能落地,选哪个看团队现状和查询形态:
| 维度 | MySQL 8.0 | PostgreSQL 14+ |
|---|---|---|
| 区间表达 | bin_start/bin_end两列 | int8range单列,原生类型 |
| 区间索引 | 联合索引 + 范围扫描 | GiST,直接索引@> |
| 最长前缀 | ORDER BY bin_len DESC LIMIT 1 | ORDER BY 区间宽度 ASC LIMIT 1 |
| 区间排他约束 | 无原生支持,只能靠脚本 | 有EXCLUDE USING gist,但本例不能用 |
| 批量导入 | LOAD DATA LOCAL INFILE,需开local_infile | COPY/\copy,服务端读文件需权限 |
| 幂等导入 | INSERT IGNORE或ON DUPLICATE KEY UPDATE | ON CONFLICT DO NOTHING |
| 运维熟悉度 | 普遍较高,DBA 覆盖广 | 需要单独的经验积累 |
| 适合场景 | 已有 MySQL 主库,BIN 表作为附属字典 | 风控/清分本身就是 PG,需要复杂区间运算 |
我的实际做法是:主库是什么就用什么,不要为了一个字典表引入第二套数据库。BIN 表的数据量在几万到几十万行,两边的性能差异在高并发下都不到一个数量级,而多维护一套数据库的运维成本是实打实的。真要做区间密集运算(比如多条号段规则求交、求差),再把这一块拆到 PG 上去。
5. 避坑与排查:BIN 表上线后最常翻车的四个场景
下面四条都是从「线上报障 → 查数据 → 定位代码」这条路上走出来的,症状、原因、解法都按当时的样子记。
5.1 同一张卡在路由侧和风控侧判出两个卡种
现象:同一笔交易,路由日志里写的是DEBIT,风控日志里写的是CREDIT,两边读的是同一张card_bin表。
原因:两侧用的版本号不同。路由侧读的是version_id = 202403,风控侧代码里硬编码了202401,而这两个版本之间正好有一条 6 位号段从贷记调整成了借记。这类问题在数据更新前永远不会暴露,一更新就集中爆发。
解决:把版本号从代码里彻底拿掉,改成一个独立的bin_version配置项,所有消费方都从一个配置中心读同一个值,并且这个值必须落进日志。之后再做一次全局搜索,把所有形如version_id = 2024的字面量全部清掉——这类硬编码在多人协作的项目里几乎必然存在,只是平时没人注意。
5.2 批量跑批时查询 QPS 掉到个位数
现象:日终跑批时 BIN 查询接口的 P99 从 3ms 涨到 800ms,监控上看数据库 CPU 打满,慢查询日志里全是同一条 SQL。
原因:用了 3.1 里说的LIKE CONCAT(prefix, '%')写法。单条查询扫几万行还能忍,跑批时并发 200 上去,buffer pool 被反复冲刷,命中率掉到 30% 以下,磁盘 IO 直接顶满。执行计划里那个type=ALL平时没人看,出事的时候才想起来。
解决:换成 3.4 的写法 B,走idx_lookup。改完之后同一条 SQL 的rows从几万降到 3,P99 回到个位数毫秒。改的过程中发现一个附带问题:应用层对同一个卡号在一个事务里重复查了 4 次(路由一次、风控一次、费率一次、清分一次),加一层进程内缓存(key 是version_id + card_prefix8,TTL 到版本切换为止)之后数据库压力再降一个量级。
5.3 新版本上线后,老卡号的归属全变空
现象:切到新版本号之后,一部分历史卡号查不到任何记录,接口返回空,下游逻辑当成「未知卡种」走了默认费率。
原因:新版本只导入了增量号段,没有把全量号段一起灌进去,实现上做成了「新版本只含本次新增」。这在设计阶段看起来合理(省空间),但查询逻辑是「按版本精确匹配」,不是「按版本倒序找最近一条」,于是老号段在新版本里就消失了。
解决:定了两条规则。第一,每个版本必须是全量快照,不允许增量覆盖,空间代价换来的是查询逻辑的简单和回滚的安全。第二,切换前跑一次全量对拍:从旧版本随机抽 5000 条卡号,分别打新旧两个版本,卡组织和卡种必须 100% 一致,不一致的落文件人工核。这套对拍脚本后来变成了上线的强制卡点,比任何评审都管用。
5.4 某条号段的生效日期写成了 2099 年
现象:运营反馈某批卡在系统里查不到卡等级,其他字段都正常。
原因:源表里有一列「生效日期」,清洗脚本把它当成了字符串直接入库,其中一行被误填成2099-01-01。查询逻辑里带了AND effective_date <= now(),这条记录就永远查不出来。更麻烦的是这个字段在告警里没人关注,日志只打card_level=NULL,看上去像数据缺失。
解决:一是把「生效日期」和「版本号」解耦——生效日期只用于人工核对,不参与运行时查询条件,查询只认version_id。二是加一条导入期校验:任何effective_date超过当前时间 5 年的记录,直接进 reject 文件并告警。三是给所有NULL字段的返回加埋点,按字段统计空值率,超过阈值就报警。第三条上线之后又抓到两个类似的隐性字段问题,收益比预期大。
6. 版本化热更新:把 BIN 表从静态字典变成可灰度切换的数据
做到这里,表能查了、数据也干净了,剩下最后一件事:怎么在不重启、不停服的情况下换一版号段。做法是把「版本」当成数据的一部分,而不是部署的一部分。
6.1 版本表加一个切换开关
单独建一张card_bin_version表,记录version_id、来源文件名、导入时间、行数、状态(staging/active/retired)。导入流程固定成三步:灌进staging,跑全量对拍,对拍通过后把旧版本置retired、新版本置active,整个过程是一条事务里的两条UPDATE。服务端不缓存版本号,而是缓存「版本号 + 解析后的字典」,用版本表里的updated_at做失效判断,这样切换对所有实例是最终一致的,中间最多有一个缓存 TTL 的窗口,不会出现两个实例长期读不同版本。
-- switch_version.sql BEGIN; UPDATE card_bin_version SET status = 'retired', retired_at = now() WHERE status = 'active' AND version_id <> 202403; UPDATE card_bin_version SET status = 'active', activated_at = now() WHERE version_id = 202403 AND status = 'staging'; -- 两个 UPDATE 影响行数必须是 1 和 1,否则回滚 COMMIT;两个UPDATE的影响行数必须严格校验,达不到就ROLLBACK。这个检查拦下过一次事故:有人手工把两个版本都标成了active,如果没有影响行数校验,切换会静默成功,然后服务端随机读到两个版本中的一个。
6.2 上线前的回归自检脚本
最后一层防线是一个跑得快、结果确定的回归脚本。它不依赖线上流量,只用一份固定的卡号样本集,覆盖三种前缀长度、四类卡种、以及若干条已知的「父子号段共存」样本。
# regress_bin.py from luhn import luhn_ok # 略,标准 Luhn 实现 def regress(conn, samples, expect_version): """samples: [(card_no, expect_brand, expect_type), ...]""" fails = [] for card_no, brand, ctype in samples: if not luhn_ok(card_no): fails.append((card_no, "luhn", None)); continue n8 = int(card_no[:8].ljust(8, "0")) row = query_bin(conn, n8, expect_version) # 3.4 写法 B if row is None: fails.append((card_no, "miss", None)); continue if row["card_brand"] != brand or row["card_type"] != ctype: fails.append((card_no, f"{row['card_brand']}/{row['card_type']}", f"{brand}/{ctype}")) return failscard_no[:8].ljust(8, "0")这一句是血泪教训:测试数据里混着 7 位的假卡号,不补零直接转整数会移位,导致所有样本都 miss,看起来像「新版本数据全丢」,实际是脚本自己的问题。Luhn 校验放在最前面,是为了在定位数据问题之前先排除掉明显非法的样本,避免噪声掩盖真正的失败。
样本集不要自动生成,要人工维护并随每次线上事故补充一条。跑到现在这个集子有 300 多条,一年下来它拦住了三次号段覆盖错误和两次卡种标错,比任何静态检查都直接。
我自己有个习惯,每次换版本之前先把新库里的bin_len = 6的号段数、card_type的空值率、以及总行数三个数字抄在变更单上,切完之后对着看一遍。有两次就是靠这三个数字发现导入少了一半——数字对不上,后面所有事都不用做了。希望帮到你。
本文还有配套的精品资源,点击获取