资讯动态

银行卡BIN数据工程化:Excel/MySQL导入、最长前缀匹配与缓存维护

发布时间:2026/10/27 17:04:59 来源:尧图企业网站定制
简介面向支付系统开发、数据分析及银行卡业务查询场景这份数据包整理自银联2020年4月25日发布的银行卡BIN信息覆盖银行卡BIN、卡BIN长度、发卡行、银行卡名称、卡类型与卡号长度等核心字段共9868条可用于卡种识别、发卡行匹配、支付路由校验及测试数据构造。压缩包共7个文件以5个xls表和1个sql脚本为主另含系统缓存文件整体约1.12MBExcel适合人工核对与二次筛选SQL脚本可直接导入MySQL使用。数据按非标卡、农民工卡、跨行转账卡、单位结算卡及通用卡表分类整理便于按业务口径快速定位。已有3497人学习下载适合需要较全BIN底表进行系统开发、数据清洗和业务验证的读者参考使用。1. 银行卡 BIN 数据不只是查一下卡号前 8 位能确定入库依据支付路由、风控规则、清分对账最后都会落到同一个动作上拿到一串卡号判断它属于哪家发卡机构、是借记还是贷记、什么等级的卡。这个判断的原料就是银行卡 BIN 数据——卡号前 6 到 8 位与发卡机构、卡属性之间的映射表。手上这份资源是 2020 年的快照给了 Excel 和 MySQL 两种形态Excel 用来核对字段、做人工抽查和二次加工MySQL 那份是已经建好结构的数据省掉了从表格往库里搬的第一道工序。它适合做支付网关、风控规则引擎、清结算对账、数据仓库卡属性打标的人。但要先说明边界BIN 数据有强时效性2020 版对之后新发的卡段覆盖有限把它当成静态真值直接用迟早会翻车正确用法是当一个可维护的基础字典来对待。2. 先说清 Excel 的字段结构BIN 表的列设计与类型边界2.1 一张能用的 BIN 表最少要有哪些列拿到 Excel 第一件事不是导入是逐列对一遍语义。BIN 表看起来简单实际上不同的整理口径差别很大——有的只给 BIN 和发卡行名称有的会给到卡等级、卡品牌、省份城市甚至清算网络标识。字段越全后面能做的规则越多但错误字段被当成真值用代价也越大。下面是我拿到这类表时默认核对的一份清单字段名可能和原件不完全一致但语义基本对得上字段含义建议类型关键说明bin卡 BIN 号VARCHAR(8)定长字符串绝不能是整型bin_lenBIN 有效长度TINYINT6 位和 8 位并存是匹配逻辑的核心brand卡品牌/清算标识VARCHAR(16)用于路由选择card_type借记/贷记/准贷记VARCHAR(16)决定是否走授信类规则card_level普卡/金卡/白金/钻石VARCHAR(24)权益与费率分档依据issuer发卡机构名称VARCHAR(64)展示与人工核对用issuer_code机构标识码VARCHAR(16)联表主键比名称可靠country国家/地区CHAR(2)跨境判断province / city归属省市VARCHAR(32)区域类规则缺失率高status是否在用TINYINT停用不等于删除见第 6 章updated_at数据版本日期DATE快照类资源必须有这里最容易出错的是bin的类型。它长得像数字很多人第一反应是INT或者BIGINT。问题在于 BIN 是标识符不是数量一旦用整型前导零会被吃掉部分卡段确实以 0 开头长度信息也会丢——062和62在整型里是同一个值但在 BIN 语义里完全是两个东西。字符串类型还顺带解决了另一个问题6 位 BIN 和 8 位 BIN 可以放在同一列里靠bin_len区分不用拆两张表。提示bin_len这一列很多人觉得冗余反正LENGTH(bin)能算出来。但导入过程里 BIN 列被清洗、补零、截断之后原始长度就找不回来了保留一列显式长度是给自己留的后悔药。2.2 Excel 打开就丢精度的三个地方Excel 对看起来像数字的东西有强烈的自动转换冲动BIN 表恰好整张都是这种内容。我踩过的坑集中在三处。第一处是科学计数法。BIN 本身 6 到 8 位还不至于溢出但很多整理版会附带一列卡号样例那是 16 到 19 位双击打开 Excel 的瞬间就变成6.22202E18再保存回 CSV 就永久损失了后几位。第二处是前导零以 0 开头的卡段被转成数值后直接少一位。第三处更隐蔽单元格里混进了不可见字符比如从网页复制带来的零宽空格、全角空格、不换行空格肉眼看列宽正常WHERE bin 622202就是查不出来。解决办法不是打开 Excel 手工设格式——几万行你也设不过来。直接用 pandas 以全字符串模式读读进来之后统一清洗import pandas as pd import re SRC bank_bin_2020.xlsx # dtypestr所有列按字符串读绕开 Excel 的数值推断 # keep_default_naFalse避免 NA、NULL 这类合法文本被当成空值 df pd.read_excel(SRC, sheet_name0, dtypestr, keep_default_naFalse) # 列名可能带 BOM\ufeff或首尾空格先归一化 df.columns [c.strip().lstrip(\ufeff) for c in df.columns] # 全表去掉零宽空格、不换行空格、全角空格 df df.apply(lambda s: s.str.replace(r[\u200b\u00a0\u3000], , regexTrue).str.strip()) # 体检 1BIN 必须是 6~8 位纯数字 bad_bin df[~df[bin].str.fullmatch(r\d{6,8})] # 体检 2BIN 重复同一 BIN 出现在多行通常是新旧数据叠加 dup_bin df[df.duplicated(bin, keepFalse)].sort_values(bin) # 体检 3发卡机构名称为空这类行在匹配时会返回未知 empty_issuer df[df[issuer] ] print(f总行数 {len(df)}非法 BIN {len(bad_bin)}重复 BIN {len(dup_bin)}机构名为空 {len(empty_issuer)}) print(bad_bin[bin].head(10).tolist())dtypestr是关键参数它让 pandas 不做类型推断直接把单元格当文本取。keep_default_naFalse同样重要否则NA、N/A、null这些字符串会被静默转成NaN你以为数据完整其实已经缺了一块。清洗正则里那三个码位分别对应零宽空格、不换行空格和全角空格是从网页或聊天工具复制数据时最常见的污染源。跑完这段重点看三个数字。非法 BIN 数不为零说明有行需要人工介入通常是长度不够或者是表头被当成了数据重复 BIN 数不为零就得判断哪一行是新的不能简单drop_duplicates了事机构名为空的行数如果超过百分之一这张表的可用性就要打问号了。2.3 重复 BIN 到底该留哪一条重复这件事值得单独说。BIN 表出现重复行一般有三种原因多批次数据叠在一起没去重、同一个 6 位前缀被拆成了多条 8 位细分、以及 BIN 被回收后重新分配给另一家机构。第一种情况直接按updated_at取最新的一条即可。第二种情况千万不能去重——6 位的620000和 8 位的62000001是两条合法记录前者是兜底后者是细分共存才是正确状态这也是为什么主键只能建在bin上而不能建在LEFT(bin,6)上。第三种情况最麻烦同一个 BIN 在不同时间属于不同机构这时候最新不一定对得看你的业务是查历史交易还是查实时交易。如果是查历史正确做法是给表加上生效时间和失效时间做成拉链表如果只是实时识别保留最新一条并且把旧的status置为 0 就够。我在实际项目里一般会保留一份bin_info主表加一份bin_info_history主表只放当前有效的记录历史表按(bin, valid_from)存。代价是多一张表和一个写入逻辑收益是半年后有人问这个 BIN 当时为什么归到这家机构你能答得上来。3. 导入 MySQL建表、LOAD DATA 与索引的三段式3.1 建表语句为什么主键是 bin 而不是自增 idBIN 表的数据量不大几万行是常态几十万行已经算非常全的口径。这个量级下讨论分库分表没有意义重点是把主键和索引设计对让查询能用上索引。CREATE TABLE bin_info ( bin VARCHAR(8) NOT NULL COMMENT 卡BIN6或8位, bin_len TINYINT UNSIGNED NOT NULL COMMENT BIN有效长度, brand VARCHAR(16) NOT NULL DEFAULT COMMENT 卡品牌/清算标识, card_type VARCHAR(16) NOT NULL DEFAULT COMMENT 借记/贷记/准贷记, card_level VARCHAR(24) NOT NULL DEFAULT COMMENT 卡等级, issuer VARCHAR(64) NOT NULL DEFAULT COMMENT 发卡机构名称, issuer_code VARCHAR(16) NOT NULL DEFAULT COMMENT 机构标识码, country CHAR(2) NOT NULL DEFAULT CN, province VARCHAR(32) NOT NULL DEFAULT , city VARCHAR(32) NOT NULL DEFAULT , status TINYINT NOT NULL DEFAULT 1 COMMENT 1在用 0停用, updated_at DATE NOT NULL COMMENT 数据版本日期, PRIMARY KEY (bin), KEY idx_bin_len (bin_len), KEY idx_issuer (issuer), KEY idx_status_len (status, bin_len) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci;主键用bin而不是自增id理由是这张表的天然唯一键就是 BIN 本身加一个无业务含义的自增列只会多一次回表。InnoDB 的主键索引是聚簇索引bin做主键意味着数据按 BIN 顺序物理存放WHERE bin IN (...)这类查询直接走主键代价最低。字符集用utf8mb4主要是为了发卡机构名称里可能出现的生僻字和特殊符号。COLLATE在 8.0 上可以换成utf8mb4_0900_ai_ci如果目标环境是 5.7 就用utf8mb4_general_ci两者在纯数字和常规中文上的表现没有实质差别。idx_status_len这个组合索引看着奇怪实际是给只查在用记录并按长度过滤这类查询准备的比如做全量预热时一次性捞出所有status1的 8 位 BIN。注意建表时给每个字段加上DEFAULT 而不是允许NULL。BIN 表里NULL和空字符串在业务上是一个意思允许NULL只会让后面的IFNULL、COALESCE到处飞还影响索引选择性。3.2 LOAD DATA 导入编码、分隔符与行数对账几万行的数据用INSERT一条条插是浪费时间用LOAD DATA LOCAL INFILE通常几秒钟搞定。前提是先把 Excel 另存成 UTF-8 编码的 CSV注意是 UTF-8 而不是CSV UTF-8逗号分隔之外的其他编码Excel 默认的 GBK 导进去中文全是问号。先确认服务端和客户端都允许本地导入# 服务端确认 local_infile 已开启 mysql -u root -p -e SHOW VARIABLES LIKE local_infile; # 若为 OFF可在会话级开启需要相应权限 mysql -u root -p --local-infile1 bin_db然后在会话里执行导入LOAD DATA LOCAL INFILE /data/bank_bin_2020.csv INTO TABLE bin_info CHARACTER SET utf8mb4 FIELDS TERMINATED BY , OPTIONALLY ENCLOSED BY LINES TERMINATED BY \n IGNORE 1 LINES (bin, bin_len, brand, card_type, card_level, issuer, issuer_code, country, province, city, status, updated_at) SET bin LPAD(TRIM(BOTH FROM bin), 6, 0), bin_len CHAR_LENGTH(TRIM(BOTH FROM bin)), status IF(TRIM(status) , 1, CAST(TRIM(status) AS UNSIGNED));几个参数值得逐条说。CHARACTER SET utf8mb4必须显式指定因为客户端默认字符集可能是 latin1不写这一句中文会静默变成乱码而且不报错——这是最难查的一类问题。OPTIONALLY ENCLOSED BY 处理字段里含逗号的情况发卡机构名称带括号带逗号很常见。IGNORE 1 LINES跳过表头。最后的SET子句是重点bin这种带的是用户变量先把原始值接进来再做转换写入真实列。LPAD(..., 6, 0)是补前导零的兜底防止 CSV 在某次另存时又把开头的 0 吃掉了。bin_len用CHAR_LENGTH现算而不是信 CSV 里的值是因为我见过太多长度列和实际值对不上的表。status用IF处理空值避免整列变成 0 导致全表停用。导完立刻对账三步-- 1) 行数是否与源文件一致先减去表头 1 行 SELECT COUNT(*) AS total FROM bin_info; -- 2) BIN 长度分布正常应该只有 6 和 8 两种 SELECT bin_len, COUNT(*) FROM bin_info GROUP BY bin_len ORDER BY bin_len; -- 3) 抽查中文是否乱码以及机构名称为空的比例 SELECT issuer, COUNT(*) c FROM bin_info WHERE issuer GROUP BY issuer ORDER BY c DESC LIMIT 10; SELECT SUM(issuer ) / COUNT(*) AS empty_ratio FROM bin_info;长度分布那一句最能说明问题。如果查出来有 5 位、7 位、9 位说明源数据里有脏行或者LPAD把本该 8 位的补成了别的长度得回头查。乱码检查不能只看SELECT有些客户端显示正常但库里存的是错的稳妥做法是比对某个已知机构名的十六进制长度。3.3 索引与执行计划让前缀匹配真正走得上索引这一步是很多人翻车的地方。BIN 查询的直觉写法是这样-- 反例索引失效 SELECT issuer FROM bin_info WHERE 6222021234567890 LIKE CONCAT(bin, %);这句在逻辑上是对的但它把列bin放到了函数参数里MySQL 无法使用索引只能全表扫描逐行拼接比较。几万行的时候感觉不出来一旦这个查询在支付链路上每秒几千次CPU 立刻打满。正确写法是把卡号截断后做等值匹配SET card 6222021234567890; SELECT bin, bin_len, issuer, card_type, card_level FROM bin_info WHERE bin IN (LEFT(card, 8), LEFT(card, 6)) ORDER BY bin_len DESC LIMIT 1;用EXPLAIN验证一下EXPLAIN SELECT bin, issuer FROM bin_info WHERE bin IN (LEFT(6222021234567890, 8), LEFT(6222021234567890, 6));正常情况下type应该是range或constkey显示PRIMARY。如果看到typeALL且rows等于全表行数说明没走索引回头检查bin列的类型是不是被改成了整型或者查询里是不是不小心加了函数包裹。ORDER BY bin_len DESC这一句是整段逻辑的核心同一张卡可能同时命中 6 位和 8 位两条记录8 位是细分、6 位是兜底必须优先取长的。LIMIT 1保证只返回一条。这个组合在数据量几万行时响应在毫秒级够用了。4. 最长前缀匹配SQL、内存字典与缓存三层实现4.1 SQL 分级匹配的边界在哪第 3 章给的IN ORDER BY LENGTH写法本质是把最长前缀匹配退化成了有限层级匹配。它只处理 6 位和 8 位两种情况足够覆盖绝大多数场景因为 ISO/IEC 7812 体系下 IIN 就这两种长度。但它有两个明确的边界。第一个是当同一张卡同时命中多条 8 位记录时数据质量问题导致LIMIT 1返回哪条是不确定的需要再加一个次级排序字段比如ORDER BY bin_len DESC, updated_at DESC。第二个是当你要批量处理几十万条卡号时一条条的IN查询会有明显的网络往返开销这时候该换方案了。批量场景可以一次把候选集合捞出来在应用层做匹配-- 一次捞出 6 位前缀集合交给应用层做最长匹配 SELECT bin, bin_len, issuer, card_type, card_level FROM bin_info WHERE status 1 AND bin_len 6;几万行结果集一次拉回来也就几 MB比循环发几万次查询快两个数量级。4.2 应用侧两级字典与内存占用测算真正高频的调用场景我一般不在请求链路上查库而是在服务启动时把整张表加载成内存字典。原因很直接BIN 表的变更频率是以月为单位的而查询频率是以毫秒为单位的用内存换数据库连接是划算的。import csv from typing import Optional class BinIndex: 两级字典8 位优先6 位兜底 def __init__(self) - None: self._d8: dict[str, dict] {} self._d6: dict[str, dict] {} self.version: str def load(self, csv_path: str, version: str) - None: d8, d6 {}, {} with open(csv_path, encodingutf-8-sig, newline) as f: for row in csv.DictReader(f): bin_no row[bin].strip() # 只接受 6/8 位纯数字脏数据直接丢弃并计数 if len(bin_no) not in (6, 8) or not bin_no.isdigit(): continue (d8 if len(bin_no) 8 else d6)[bin_no] { issuer: row.get(issuer, ), card_type: row.get(card_type, ), card_level: row.get(card_level, ), brand: row.get(brand, ), } # 整体替换避免加载过程中读到半截数据 self._d8, self._d6, self.version d8, d6, version def lookup(self, card_no: str) - Optional[dict]: if not card_no or not card_no.isdigit() or len(card_no) 6: return None # 先试 8 位明细 hit self._d8.get(card_no[:8]) if hit is not None: return hit # 再退 6 位兜底 return self._d6.get(card_no[:6])encodingutf-8-sig这个参数是专门对付 BOM 的用utf-8读带 BOM 的文件第一列的列名会变成\ufeffbin然后row[bin]直接KeyError。load里用局部变量拼完再整体赋值是为了避免热更新时读到一半新一半旧的状态——这个细节在低峰期无所谓在高峰期就是资损级问题。内存占用可以粗算一条记录按 4 个字段、每字段平均 12 字节算加上 dict 的哈希表开销单条约 200 字节。10 万条就是 20 MB对一个常规服务实例完全无压力。如果 BIN 表到了百万级现实中很少见再考虑上 Trie 或者把 8 位字典改成分片加载。4.3 缓存层Redis 预热与版本切换多实例部署时每个进程各加载一份内存字典会有更新不同步的问题——A 实例已经切到新版本B 实例还是旧数据同一张卡在两个实例上返回不同的发卡行。这种不一致在下游做对账时非常难查。常见做法是把 BIN 数据放 Redis应用侧加一层本地缓存用版本号控制切换import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) def warm_up(rows: list[dict], version: str) - None: 全量写入一个新版本前缀写完再切指针 pipe r.pipeline(transactionFalse) for row in rows: key fbin:{version}:{row[bin]} pipe.hset(key, mapping{ issuer: row[issuer], card_type: row[card_type], card_level: row[card_level], }) pipe.expire(key, 7 * 24 * 3600) # 给旧版本留 7 天回收窗口 pipe.execute() # 指针切换是原子操作应用侧读到的永远是完整版本 r.set(bin:current, version) def lookup(card_no: str) - dict | None: version r.get(bin:current) for n in (8, 6): if len(card_no) n: continue h r.hgetall(fbin:{version}:{card_no[:n]}) if h: return h return None用bin:{version}:{bin}这种带版本前缀的 key好处是全量刷新时不用删旧数据直接写新版本再切bin:current指针切换瞬间生效且无空窗。pipeline(transactionFalse)是为了吞吐这里不需要事务语义因为版本隔离已经保证了读一致性。expire设 7 天是给回滚留后路——新版本发现有错把指针切回上一版就行数据还在。提示decode_responsesTrue记得开否则hgetall返回的是 bytes后面拼 JSON 或者做字符串比较时会踩坑。5. 避坑清单导入、匹配、维护里最容易翻车的五件事5.1 数据进库阶段坑一Excel 另存 CSV 后 BIN 变成科学计数法。现象是导入后bin_len分布里出现一堆 9 位、11 位的记录或者SELECT bin出来是6.22202E18。原因是那一列在 Excel 里被识别成数值另存时按浮点写进了 CSV。解决办法是在导出前把该列设为文本格式或者干脆跳过 Excel用 pandas 读完直接to_csv(indexFalse, encodingutf-8-sig)全程不经过 Excel 的计算引擎。我现在的习惯是任何表格数据只用 pandas 做中转Excel 只用来肉眼看看。坑二UTF-8 BOM 导致第一列列名对不上。现象是LOAD DATA报Unknown column \ufeffbin in field list或者 Python 里KeyError: bin。原因是 Excel 另存的CSV UTF-8会在文件头写三个字节的 BOM。解决办法是读的时候用utf-8-sig编码或者导入前用sed -i 1s/^\xEF\xBB\xBF// file.csv把 BOM 削掉。两种都行关键是要意识到这个问题的存在——它不报错的时候更可怕BOM 会被当成第一个字段名的一部分然后你所有的字段映射全部错位一格。坑三6 位和 8 位混排按 6 位统一截断。现象是某些卡的卡等级全被识别成普卡或者发卡行识别率明显低于预期。原因是整理时为了统一把所有 BIN 截成了 6 位8 位细分记录被丢弃了。解决办法是保留原始长度用bin_len区分查询时做两级回退。如果手上的数据已经只有 6 位那就接受这个精度别再假装能细分——识别不出来比识别错了要好。5.2 匹配与维护阶段坑四用LIKE CONCAT(bin,%)做前缀匹配。现象是单条查询几十毫秒压测到几百 QPS 时数据库连接池直接打满慢查询日志里全是这一条。原因是列被包在了函数里索引失效走的是全表扫描。解决办法是改成LEFT(card_no, 8) IN (...)的等值匹配或者按 4.2 的方案把数据加载到内存。这个坑我在两个项目里见过两次都是上线后才发现因为测试数据量太小全表扫描也就几毫秒。坑五把 2020 快照当永久真值。现象是运行一年后未知发卡行的比例从 2% 涨到 15%风控规则大面积命中默认分支。原因有两个一是新卡段持续发行老表覆盖不到二是 BIN 会被回收再分配同一个 BIN 隔几年可能属于另一家机构老表给的还是旧机构。解决办法是建立版本意识——给表加updated_at把查不到和查到旧值当成两种不同的业务状态分别处理前者走降级策略后者至少要打个标记。同时约定一个季度级的更新节奏别等到出问题才想起来。这五条里坑四和坑五是最容易被低估的。坑四在开发环境永远测不出来坑五在项目刚上线的前半年也不会暴露都是那种看起来一切正常的问题。6. 让这份快照长期可用Luhn 预校验、增量合并与回归用例数据进了库、查询也跑通了剩下的问题是怎么让它活过一年。我一般会做三件事。第一件是在 BIN 匹配之前加一道 Luhn 校验。Luhn 算法模 10 校验能挡掉大部分脏数据——用户手输错的、测试环境造的、爬来的假卡号过不了校验的直接不进 BIN 查询省一次 IO 也省一次误判。但它只能验证格式合法性不能验证卡号是否真实存在所以校验通过之后该查 BIN 还是得查别把 Luhn 当成有效性证明。def luhn_ok(card_no: str) - bool: 模 10 校验从右往左偶数位乘 2超过 9 减 9 if not card_no.isdigit() or not (12 len(card_no) 19): return False total, alt 0, False for ch in reversed(card_no): d ord(ch) - 48 if alt: d * 2 if d 9: d - 9 total d alt not alt return total % 10 0第二件是增量合并而不是全量覆盖。新一期数据来了之后按bin做 upsert消失的 BIN 不物理删除只把status置 0。原因很简单老交易还在库里历史查询还得靠这些记录解释当时为什么这么路由。INSERT INTO bin_info (bin, bin_len, brand, card_type, card_level, issuer, issuer_code, country, province, city, status, updated_at) VALUES (62220201, 8, 示例品牌, 贷记卡, 金卡, 某全国性银行, X001, CN, 某省, 某市, 1, 2020-12-01) ON DUPLICATE KEY UPDATE issuer VALUES(issuer), card_type VALUES(card_type), card_level VALUES(card_level), brand VALUES(brand), status VALUES(status), updated_at VALUES(updated_at);ON DUPLICATE KEY UPDATE依赖主键bin判重所以主键设计对了这里才能一行搞定。注意VALUES()函数在较新版本里被标记为待废弃如果目标环境支持可以换成行别名写法AS new ON DUPLICATE KEY UPDATE issuer new.issuer两种在实际项目里都能跑。第三件是回归用例。每次数据更新完跑一批固定卡号比对期望的发卡行和卡类型任何一条不一致就阻断发布。这比人工抽查靠谱得多也花不了几分钟。验证维度检查方法通过标准总量COUNT(*)与上一版对比波动 3%否则人工确认长度分布按bin_len分组只有 6 和 8 两种取值关键卡段抽样 200 条跑回归脚本发卡行一致率 100%未知率用历史卡号池回放未知比例不高于上一版停用记录统计status0的数量只增不减且能追溯到版本最后一句血泪经验这份 2020 的数据包最大的价值不是那几万行记录本身而是它给了一套完整的字段口径和两份可直接使用的格式。字段口径能复用格式能复用里面的具体数据必须当成有保质期的东西来维护。从那以后我每次接这类快照型数据资源第一件事都是先建updated_at和回归用例再谈导入——顺序反了后面全是补丁。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑