简介:面向支付系统开发、银行卡识别、风控校验及数据清洗等场景,整理了一份2020年4月25日银联官方发布的银行卡BIN数据,适合需要判断发卡行、卡种与卡号长度规则的开发者、测试及数据分析人员使用。压缩包共7个文件,以xls表格和sql脚本为主,整体约1.12MB;Excel便于直接筛选核对,SQL文件已整理为可导入结构,能较快落到MySQL等业务库中。数据合计9868条,覆盖BIN、BIN长度、发卡行、银行卡名、银行卡类型、银行卡长度等关键字段,并区分标准卡、农民工卡、跨行转账、单位结算等卡表信息,便于按业务类型分别查询。已有3497人学习下载,可作为银行卡信息匹配、支付通道识别、测试数据构造和存量数据校验的基础资料,减少自行收集与清洗成本。
1. 银行卡BIN数据包到底解决什么问题:从Excel到MySQL的一条落地链路
支付路由或风控服务里,经常要在下单前判断一张卡是借记还是信用、卡号长度是否落在发卡行区间、BIN段属于哪个发卡机构。靠 if 写死几个前缀,维护成本高,遇到新卡段还会误判。银行卡BIN数据(Excel+MySQL)就是把这个判断外置成一张可查询的表:Excel负责人工核对和版本留档,MySQL负责线上低延迟查询。
2020版数据包的价值不在“全”,而在字段齐、能直接落库、能对账。资源名里带“官方发布”和“最新最全”,实际落地时仍要按批次数据对待,不要把它当成实时主数据。适合支付网关、收单系统、测试环境造数和风控规则同学,不适合拿它当实时主数据。
2. 先看清字段再建表:Excel列映射与MySQL表结构设计
拿到包后我先做一件事:把Excel第一行当合同看。BIN数据最怕的不是字段少,而是字段含义含糊。同一个“卡类型”,有的包写“借记卡”,有的写“DEBIT”,还有的写“D”。建表前不统一,后面查询就会写出三套分支。我的习惯是先抽样50行,把每列取值分布打印出来,再决定字段类型和枚举映射。
2.1 常见字段拆解与类型选择
BIN号必须是字符串,不能是整数。原因很直接:部分BIN以0开头,Excel一旦按数值存,前导零就丢了,导进MySQL再想补回来只能靠猜。字段长度建议用 CHAR(8) 存左补零后的BIN,同时保留 bin_len 记录原始长度6、7、8。查询时用左补零后的值做等值匹配,展示时按 bin_len 截取。
卡类型不要直接存中文长文本。常见做法是落库时统一成 DEBIT、CREDIT、PREPAID、UNKNOWN 四类,另建一张小映射表保存原始描述。发卡机构名称保留原文,但测试环境里不要用真实机构名,用“示例发卡行A”这类占位。卡号长度常见是一个区间,比如16到19,拆成 card_length_min 和 card_length_max 两个 TINYINT 字段,比存“16-19”字符串好查。
Luhn校验标识也建议单独一列。不是所有BIN段都强制Luhn,尤其部分预付卡和测试段。字段用 TINYINT(1),默认1,遇到包内明确写“否”再置0。source_batch 用来记批次,2020版就写2020,后面换批次不会把老数据冲掉。
2.2 建表SQL与字符集/排序规则
下面这张表是我在MySQL 5.7和8.0都能跑的版本。排序规则用 utf8mb4_unicode_ci,不追新版的 0900_ai_ci,避免从旧库迁移时排序规则不兼容。BIN字段加普通索引,不加唯一索引,原因在第5章展开。
CREATE TABLE bank_bin_2020 ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, bin_code CHAR(8) NOT NULL COMMENT 'BIN段,左补零到8位', bin_len TINYINT UNSIGNED NOT NULL COMMENT '实际BIN长度6/7/8', brand VARCHAR(32) NOT NULL DEFAULT '' COMMENT '卡组织标识,按包内取值', card_type VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'DEBIT/CREDIT/PREPAID/UNKNOWN', issuer_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '发卡机构名称,测试数据用示例发卡行A', card_length_min TINYINT UNSIGNED NOT NULL DEFAULT 0, card_length_max TINYINT UNSIGNED NOT NULL DEFAULT 0, luhn_required TINYINT(1) NOT NULL DEFAULT 1, source_batch VARCHAR(32) NOT NULL DEFAULT '2020', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_bin_code (bin_code), KEY idx_brand_type (brand, card_type), KEY idx_issuer (issuer_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;参数说明:bin_code 用 CHAR(8) 是定长等值查询友好,左补零后不会因为长度变化导致索引页分裂;bin_len 不参与匹配,只用于展示和校验;source_batch 既做版本标记,也做后续增量导入的过滤条件。created_at 用默认值,导入时不用显式赋值。idx_brand_type 是给“按卡组织和卡类型筛”的场景准备的,单查BIN走 idx_bin_code。
2.3 Excel列名不一时的映射表
不同批次的Excel列名经常带空格、大小写混用,甚至同义列。导入前先做一层列名归一化,别在SQL里写一堆 IFNULL。下面这张映射表是我处理BIN包时常用的对照,列名按实际包内调整。
| Excel常见列名 | 建议字段 | 类型 | 处理方式 |
|---|---|---|---|
| BIN、卡BIN、卡号前8位 | bin_code | CHAR(8) | 读成字符串,TRIM后LPAD到8位 |
| 卡组织、品牌 | brand | VARCHAR(32) | 去空格,保留包内原始值 |
| 卡类型、借贷标识 | card_type | VARCHAR(16) | 映射为DEBIT/CREDIT/PREPAID/UNKNOWN |
| 发卡行、机构名称 | issuer_name | VARCHAR(128) | 保留原文,测试时替换为示例发卡行A |
| 卡号长度、长度范围 | card_length_min/max | TINYINT | 按“-”拆分,单值则min=max |
| 校验位、Luhn | luhn_required | TINYINT(1) | “是/Y/1”置1,“否/N/0”置0 |
映射表不要只写在文档里,落成一个Python字典或一张 column_map 表。后面换批次时,先跑一遍列名检查:缺列直接报错,多列进日志,别让多出来的列静默丢掉。Excel里如果出现合并单元格,先取消合并再另存CSV,否则导入行数会对不上。
3. 把Excel灌进MySQL:三种导入方式与可复现脚本
导入这一步最容易翻车的地方不是SQL写错,而是Excel转CSV时把前导零、长数字和引号吃掉了。我的顺序是:先在Excel里把BIN列设成文本,另存为“CSV UTF-8”,再用命令行确认文件编码和行数,最后才进MySQL。下面三种方式按数据量选,几千行用Python省心,几万行以上用 LOAD DATA 更快。
3.1 LOAD DATA LOCAL INFILE 批量导入
先确认服务端和客户端都允许 local_infile。测试环境可以临时开,生产环境按DBA规范走,不要长期全局打开。导之前把CSV字段顺序对齐建表语句,BIN列用用户变量接,避免MySQL直接把“012345”当数字。
SET GLOBAL local_infile = 1; LOAD DATA LOCAL INFILE '/data/bank_bin_2020.csv' INTO TABLE bank_bin_2020 CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' ENCLOSED BY '"' ESCAPED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (@raw_bin, @raw_bin_len, brand, card_type, issuer_name, @raw_len_min, @raw_len_max, @raw_luhn) SET bin_code = LPAD(TRIM(@raw_bin), 8, '0'), bin_len = COALESCE(NULLIF(TRIM(@raw_bin_len), ''), CHAR_LENGTH(TRIM(@raw_bin))), card_length_min = CAST(NULLIF(TRIM(@raw_len_min), '') AS UNSIGNED), card_length_max = CAST(NULLIF(TRIM(@raw_len_max), '') AS UNSIGNED), luhn_required = IF(TRIM(@raw_luhn) IN ('1','Y','yes','true'), 1, 1), source_batch = '2020';逻辑说明:@raw_bin 先接原始字符串,再用 LPAD 补到8位,这样“012345”不会变成“12345”。bin_len 优先取包内长度列,没有就用原始BIN长度兜底。card_length_min/max 用 CAST 转无符号整数,空值转0。luhn_required 这里默认置1,如果包内明确有“否”值,把 IF 表达式改成三态判断。
参数说明:FIELDS TERMINATED BY ',' 对应CSV逗号;ENCLOSED BY '"' 处理发卡行名称里的逗号;ESCAPED BY '"' 跟Excel导出习惯对齐。IGNORE 1 LINES 跳过表头。导入后立刻执行 SHOW WARNINGS,别只看“Query OK”。如果CSV是GBK,CHARACTER SET 要改成gbk,但更推荐Excel另存UTF-8。
提示:LOAD DATA 前先用
wc -l看CSV行数,再和SELECT COUNT(*)对账。行数差1通常是表头或末尾空行,差很多优先查引号换行和编码。
3.2 Python pandas + SQLAlchemy 增量导入
数据量不大、需要边清洗边导入时,我用 pandas。关键参数是 dtype=str 和 keep_default_na=False,前者防前导零丢失,后者防空字符串被转成NaN。下面脚本假设CSV已经按第2章映射表改好列名。
import pandas as pd from sqlalchemy import create_engine csv_path = "/data/bank_bin_2020.csv" engine = create_engine( "mysql+pymysql://user:pass@127.0.0.1:3306/pay?charset=utf8mb4" ) df = pd.read_csv(csv_path, dtype=str, keep_default_na=False) df.columns = [c.strip().lower() for c in df.columns] rename = { "卡bin": "bin_code", "bin": "bin_code", "卡组织": "brand", "卡类型": "card_type", "发卡行": "issuer_name" } df = df.rename(columns=rename) df["bin_code"] = df["bin_code"].str.strip().str.zfill(8) df["bin_len"] = df["bin_code"].str.lstrip("0").str.len().clip(lower=6, upper=8) df["source_batch"] = "2020" df = df[df["bin_code"].str.fullmatch(r"\d{6,8}")] cols = ["bin_code", "bin_len", "brand", "card_type", "issuer_name", "source_batch"] df = df[cols] with engine.begin() as conn: df.to_sql("bank_bin_2020", conn, if_exists="append", index=False, chunksize=2000)逻辑说明:dtype=str 让pandas按字符串读,zfill(8) 统一左补零;bin_len 用去掉前导零后的长度,再夹在6到8之间,防止异常值。fullmatch 过滤掉非数字BIN。to_sql 只传目标表存在的列,多传列会直接报错。chunksize=2000 是折中值,太大容易撑爆事务日志,太小导入慢。
参数说明:if_exists="append" 适合增量批次,首次导入也可以用;如果要覆盖,先TRUNCATE再append,不要用replace,replace会重建表丢索引。charset=utf8mb4 必须和表一致,否则发卡行名称里的特殊字符会变问号。
3.3 导入后的校验SQL与行数对账
导入完成先跑三类校验:总行数、BIN格式、空值。下面SQL直接贴进客户端就能跑,结果和CSV行数对不上时,先看 zero_bin_rows 和 empty_issuer_rows 是不是异常高。
SELECT COUNT(*) AS total_rows, SUM(bin_code REGEXP '^[0-9]{8}$') AS valid_bin_rows, SUM(bin_code = '00000000') AS zero_bin_rows, SUM(issuer_name = '') AS empty_issuer_rows, SUM(bin_len BETWEEN 6 AND 8) AS valid_len_rows FROM bank_bin_2020; SELECT bin_code, COUNT(*) AS c FROM bank_bin_2020 GROUP BY bin_code HAVING c > 1 ORDER BY c DESC LIMIT 20;第一段看总量和格式,valid_bin_rows 应该接近 total_rows;zero_bin_rows 高说明前导零处理有问题或源文件有空BIN。第二段看重复BIN,重复不一定错,BIN段复用很常见,但重复太多要回头看来源批次和发卡机构是否混在一起。
对账时别只对总数,抽10个BIN回Excel里搜一遍,确认 bin_code、issuer_name、card_type 三列没串行。CSV列顺序错位是最隐蔽的翻车,总数能对上,字段全错。
4. 查询与匹配:BIN前缀检索、索引设计和Luhn校验
线上查询BIN的典型请求是“给一个卡号前8位,返回发卡行、卡类型、卡号长度区间”。这里有两个坑:一是用 LIKE '%123456%' 导致全表扫描;二是BIN长度有6、7、8三种,直接等值匹配8位会漏掉6位段。我的做法是统一存8位左补零,查询时按8位、7位、6位依次降级,取最长命中。
4.1 前缀匹配为什么不能直接 LIKE '%'
BIN匹配本质是前缀匹配,但很多人写成WHERE bin_code LIKE CONCAT('%', ?, '%'),前后都带百分号,索引直接失效。即使只写LIKE '123456%',当参数是变量时优化器也不一定用得上索引。更稳的方式是把前缀截出来做等值匹配,或者用生成列固定前缀长度。
另一个误区是对卡号用 LEFT(card_no, 6) 去关联BIN表。LEFT 是函数,BIN表上的索引用不上,数据量一大就是全表扫。正确姿势是反方向:用卡号前8位去等值查BIN表的 bin_code,查不到再试前6位。卡号本身不落库,只在内存里截取。
4.2 生成定长前缀列与联合索引
如果业务查询固定看前6位,可以加一个生成列 bin6,MySQL 5.7和8.0都支持 STORED 生成列。这样查询WHERE bin6 = LEFT(?, 6)能走索引。下面语句给 bin6 和 bin8 各建一个索引,bin8 其实就是 bin_code 本身,重复建索引没必要,实际只建 bin6 即可。
ALTER TABLE bank_bin_2020 ADD COLUMN bin6 CHAR(6) GENERATED ALWAYS AS (LEFT(bin_code, 6)) STORED, ADD KEY idx_bin6 (bin6);查询时按最长前缀降级,用 UNION ALL 比 OR 更可控,每一段都能走自己的索引。下面示例里的卡号是虚构测试号,不要拿真实卡号跑。
SET @card_no = '9990011234567890'; SELECT * FROM ( SELECT bin_code, bin_len, brand, card_type, issuer_name, card_length_min, card_length_max, 8 AS match_len FROM bank_bin_2020 WHERE bin_code = LEFT(@card_no, 8) UNION ALL SELECT bin_code, bin_len, brand, card_type, issuer_name, card_length_min, card_length_max, 6 AS match_len FROM bank_bin_2020 WHERE bin_code = LEFT(@card_no, 6) ) t ORDER BY match_len DESC, bin_len DESC LIMIT 1;逻辑说明:先查8位,再查6位,合并后按 match_len 降序取第一条,保证最长前缀优先。7位段如果包里有,再加一段 UNION ALL。参数说明:@card_no 只用于演示,生产里用绑定变量;LIMIT 1 避免一个BIN命中多条时返回多行。如果业务需要知道“命中但长度不匹配”,把 card_length_min/max 和实际卡号长度一起带出去判断。
4.3 Python实现BIN匹配与Luhn校验
服务层拿到BIN结果后,通常还要做一次Luhn校验。Luhn不是判断卡号真伪的唯一标准,但能挡掉大部分手输错误。下面函数只做格式和校验位判断,不联网、不查库。
def luhn_ok(card_no: str) -> bool: if not card_no.isdigit() or not (12 <= len(card_no) <= 19): return False digits = [int(c) for c in card_no] checksum = 0 parity = len(digits) % 2 for i, d in enumerate(digits): if i % 2 == parity: d *= 2 if d > 9: d -= 9 checksum += d return checksum % 10 == 0 def match_bin(conn, card_no: str): for n in (8, 7, 6): row = conn.execute(text(""" SELECT bin_code, bin_len, brand, card_type, issuer_name, card_length_min, card_length_max, luhn_required FROM bank_bin_2020 WHERE bin_code = :p LIMIT 1 """), {"p": card_no[:n].ljust(8, "0")}).fetchone() if row: return row return None逻辑说明:luhn_ok 从右往左的奇偶位判断,这里用 parity 控制双倍位,长度12到19是常见卡号范围。match_bin 按8、7、6降级,card_no[:n] 截前缀后 ljust 补零,和库里的 CHAR(8) 对齐。参数说明:conn 是SQLAlchemy连接,text 里的 :p 是绑定变量,不要拼字符串。返回 row 后先校验卡号总长度是否落在 card_length_min/max 区间,再看 luhn_required 决定是否强制Luhn。
5. 避坑与排查:导入乱码、前导零丢失、重复BIN和过期数据
BIN数据包的坑集中在“看起来导入成功,实际查不准”。下面5条是我和周围同学踩过的真实类型,按现象、原因、解决写,照着排查能省不少时间。
5.1 现象:BIN号变成科学计数法或前导零消失
现象:Excel里明明是012345,导入MySQL后变成12345,或者显示成1.23E+05。查发卡行时命中不了,日志里BIN长度对不上。
原因:Excel默认把纯数字列当数值,前导零被吃掉;CSV再用数值格式写回,MySQL的 INT 字段也会把012345存成12345。另一个常见原因是pandas read_csv没加 dtype=str,空值和数字列被自动推断。
解决:源头把BIN列设成文本格式,再另存CSV UTF-8;导入时用 @raw_bin 接字符串再 LPAD;MySQL字段用 CHAR(8) 而不是 INT。已经丢零的数据不要手工补,重新从原始Excel导一遍,补零规则只认源文件。
5.2 现象:导入后行数对不上,少几十行或多一行
现象:CSV用 wc -l 是10001行,MySQL COUNT(*) 是9980,或者多出1行空记录。SHOW WARNINGS 里有“Incorrect integer value”或“Data truncated”。
原因:发卡行名称里有英文逗号,CSV没加引号导致列错位;文件末尾有空行;Excel单元格里有换行符,LOAD DATA 按行切时切坏;编码不是UTF-8,中文列截断后整行被跳过。
解决:Excel另存时选“CSV UTF-8”,发卡行名称含逗号的列用 ENCLOSED BY '"';导入前用file -i看编码,用tail -c 100看末尾有没有空行;LOAD DATA 后立刻 SHOW WARNINGS,别等业务查询报错才发现。行数差1先查表头和末尾空行,差几十查引号和换行。
5.3 现象:一个BIN查出多个发卡行,不知道该信谁
现象:同一个8位BIN在表里有两条记录,issuer_name 不一样,card_type 也不一样。线上随机返回一条,测试同学提bug说“同一张卡每次结果不同”。
原因:BIN段存在复用和迁移,不同来源批次可能都收了同一段;卡组织标识不同也会导致同一BIN挂在不同机构下;还有历史数据没清,2020版和更早版本混在一张表。
解决:不要给 bin_code 建唯一索引,唯一索引会让导入直接失败或静默丢行。查询时加 source_batch 过滤,或者按 created_at 取最新批次;业务侧定优先级,比如先看卡组织,再看BIN长度,最后看发卡机构。重复BIN要留日志,别用 LIMIT 1 掩盖。
5.4 现象:2020版数据在新卡上查不到,返回UNKNOWN
现象:2023年后的新卡段在表里查不到,match_bin 返回None,风控规则直接放过或误杀。业务方问“不是最新最全吗”。
原因:BIN段会新增和调整,2020版只覆盖那个时间点的公开段;资源名里的“最新最全”要按批次时间理解,不是实时主数据。另一个原因是查询只试了8位和6位,漏了7位段。
解决:表里保留 source_batch,查询默认走最新批次;未知BIN不要直接拒,落一个 unknown_bin 表,按卡号前6位聚合,每周看新增量。更新批次时用新表或新 source_batch,别直接 UPDATE 老数据,留回滚余地。
5.5 现象:发卡行名称显示问号或乱码
现象:MySQL里 issuer_name 显示“示例发卡行?”,或者中文变乱码。Excel里正常,导进去就不对。
原因:CSV是GBK,MySQL连接字符集是latin1,或者表排序规则不是utf8mb4;LOAD DATA 没指定 CHARACTER SET,客户端按默认编码解释。
解决:表统一 utf8mb4,连接串加 charset=utf8mb4,LOAD DATA 显式写 CHARACTER SET utf8mb4。已经乱码的批次直接重导,不要用 CONVERT 函数修,修完可能二次乱码。导入前用iconv -f GBK -t UTF-8转一遍,比在SQL里猜编码稳。
6. 进阶用法:把BIN数据做成可验证的测试与风控基线
BIN数据不只用来查发卡行。测试环境造卡号、风控规则回归、数据质量对账都能用它做基线。关键是别把“查询”当成终点,要让它可验证:每次导入后跑同一套检查,结果和上一版对比。
6.1 构造脱敏测试卡号
测试卡号用虚构BIN前缀加随机数字,再算Luhn校验位。不要用真实卡号,也不要用真实发卡行名称。下面函数依赖第4章的 luhn_ok,BIN前缀传“999001”这类测试段。
import random def build_test_card(bin_prefix: str, length: int = 16) -> str: body = bin_prefix + "".join( str(random.randint(0, 9)) for _ in range(length - len(bin_prefix) - 1) ) for d in "0123456789": if luhn_ok(body + d): return body + d raise ValueError("no check digit")逻辑说明:先拼到 length-1 位,再暴力试0到9找校验位,最多10次。参数说明:bin_prefix 只传测试段,length 传16或19;生成结果只进测试库,日志里打码。
6.2 用校验表做数据质量回归
每次换批次,跑同一张检查表,结果落 regression_log。下面五项是我必看的。
| 检查项 | 方法 | 通过标准 |
|---|---|---|
| 总行数 | COUNT(*) | 与源CSV行数一致 |
| BIN格式 | REGEXP '^[0-9]{8}$' | 通过率100% |
| 长度区间 | bin_len BETWEEN 6 AND 8 | 无越界 |
| 重复BIN | GROUP BY bin_code HAVING COUNT(*)>1 | 记录数在允许范围 |
| 空发卡行 | issuer_name='' | 低于阈值,逐条确认 |
6.3 版本管理与更新策略
表里永远留 source_batch 和 created_at,查询默认走最新批次,历史批次不删。更新时先导新表 bank_bin_2021,跑完6.2的回归,再切查询指向。未知BIN单独落表,按周聚合,超过阈值再决定是否补段。从那以后我每次导BIN数据都先跑一遍前导零和行数对账,再谈查询优化。希望帮到你。
本文还有配套的精品资源,点击获取