资讯动态

银行卡BIN号段查询:Excel清洗到MySQL/PostgreSQL匹配

发布时间:2026/10/29 15:07:02 来源:尧图企业网站定制
简介面向金融数据整理、支付风控与客户分析场景的银行卡BIN号数据包适合需要查询银行卡归属行、卡类型及发卡机构信息的开发、数据分析与运营人员使用。压缩包共7个文件以5个xls表格和2个sql脚本为主整体约1.4MB表格便于在Excel中筛选比对SQL脚本可直接导入MySQL或PostgreSQL构建查询表。内容围绕BIN号识别、归属行匹配与数据维护展开覆盖农民工卡、跨行转账卡、非标卡、单位结算卡等分类并附带2020年4月25日版数据可用于搭建基础数据表、验证归属行规则和补齐历史数据。已有861人学习下载适合作为银行卡BIN基础数据与多数据库落地的参考素材。1. 一张卡刷过去系统第一件事是猜它是谁家的支付网关的日志里躺着一条 62 开头的 19 位卡号业务方只问一句「这是哪家行的卡」你手上却只有这串数字——这种场景在支付路由、风控规则、对账分户、客服工单四类系统里天天出现。银行卡归属行查询和银行卡BIN号匹配本质是一张静态号段表的查表问题难点不在算法在数据本身有 6 位、7 位、8 位三种长度并存还有区间表示、历史更名和脏格式。这份资源把同源数据拆成三份交付一份 Excel 用于人工核对与临时查表一份 MySQL dump 直接灌进业务库一份 PostgreSQL dump 给用 PG 的团队省掉转换。它适合正在做收单路由、发卡行识别或测试数据构造的工程师也适合只想手工查几个号段归属的运营同学。2. BIN 号段的编码规则6 位、8 位和区间写法怎么对齐2.1 从 MII 到发卡机构IIN 是一层层拆下来的银行卡号的前几位在国际标准里叫 IINIssuer Identification Number早期资料里普遍写成 BIN两个词在业务系统里基本混用。第一位是 MII用来粗分行业大类4 和 5 划给银行金融6 划给商业和银行业务35、34、37 这类被国际卡组织拿走国内绝大多数借记卡和信用卡落在 62 这一段。再往后若干位才定位到具体发卡机构最后几位是卡种、卡等级和校验位。真正让工程上麻烦的是长度。历史上 IIN 普遍是 6 位于是大量老系统的匹配逻辑写死了substr(card_no, 1, 6)。后来新分配的 IIN 统一走 8 位存量数据里就出现了 6 位和 8 位混编的状态中间还夹着少量 7 位的历史号段。如果查询端只按 6 位匹配一个 8 位号段会同时命中它的 6 位父前缀可能出现「同一张卡查出两个发卡行」的翻车现场。所以这份数据在字段设计上必须同时保留号段值和号段有效长度查询端按「最长前缀优先」回退先试 8 位不中再试 7 位最后试 6 位。这套逻辑在 Excel 里靠人眼判断在数据库里靠ORDER BY bin_len DESC LIMIT 1原理一样。2.2 卡号校验和 BIN 匹配是两件完全不相干的事新手最容易把 Luhn 校验当成归属行识别的一部分其实两者没有任何关系。Luhn 只回答一个问题这串数字在形式上像不像一个合法卡号。它做的是从右往左加权求和偶数位乘 2、超过 9 减 9最后对 10 取模。一串随机生成的 16 位数字大约有十分之一的概率能通过 Luhn所以它能挡掉明显的手误和脏数据但挡不住伪造。BIN 匹配回答的是另一个问题这串数字理论上归谁发。它只依赖前缀表不关心校验位对不对。两者串起来才是完整链路先用 Luhn 和长度范围把明显不合法的输入丢掉再拿前 68 位去查表定位发卡机构最后按业务需要补上卡种、卡等级这些属性。一份合格的号段数据至少要能支撑这三个动作所以字段里必须显式带bin、bin_len、card_type、issuer_name而不能只有一个「卡号前缀 → 银行名」的两列宽表。宽表在 Excel 里看着舒服一进库就开始难受。2.3 三份格式共用的字段设计Excel、MySQL、PostgreSQL 三份交付物字段是对齐的只是载体不同。Excel 那份是宽表方便筛选和肉眼比对两份数据库 dump 是窄表带主键、唯一约束和索引。统一的字段口径如下字段类型建议含义取值说明binvarchar(8)发卡行识别码纯数字68 位首位非 0bin_lentinyint / smallint号段有效长度6、7、8 三选一card_orgvarchar(16)卡组织归属DOMESTIC / INTLcard_typevarchar(16)借贷记类型DEBIT / CREDIT / PREPAIDcard_levelvarchar(16)卡等级普通、金卡、白金、钻石等issuer_namevarchar(128)发卡机构名称中文全称region_codechar(2)国家或地区代码ISO 3166-1 alpha-2statustinyint是否有效1 有效0 已下线需要强调的是bin的类型。几乎所有把 BIN 存成整型的库最后都踩了坑原因不在于数值本身放不下而在于查询写法会被隐式类型转换带偏WHERE bin LIKE 622202%在整型列上会退化成全表扫描加索引也没用。字符型是唯一稳妥的选择。3. Excel 版先跑通清洗、区间展开与 CSV 中转3.1 用 dtypestr 挡住 Excel 的类型推断拿到手的 Excel 大概率不是干净的。号段列可能被存成数字显示成6.22202E05也可能混着全角横线和中文空格发卡机构名里带不可见字符导致后面 group by 时同一个银行被拆成两条。人工改几十行还行几万行就只能靠脚本。清洗的第一步不是改数据是阻止 pandas 替你做类型推断。默认参数下read_excel会把看起来像数字的列转成 int64 或 float64一旦转成浮点622202就变成622202.0再转回字符串就是脏值。import re import pandas as pd RAW card_bin_raw.xlsx # dtypestr 是核心整列按文本读入杜绝 622202 - 622202.0 # keep_default_naFalse缺失值给空串而不是 NaN避免后面写出 nan df pd.read_excel( RAW, sheet_namebin, dtypestr, keep_default_naFalse, engineopenpyxl, ) # 列名标准化去首尾空格、转小写、空格换下划线 df.columns [re.sub(r\s, _, c.strip().lower()) for c in df.columns] # 号段列去中文全角空格、把全角连字符统一成半角 df[bin] ( df[bin].astype(str) .str.strip() .str.replace(\u3000, , regexFalse) .str.replace(, -, regexFalse) )dtypestr和keep_default_naFalse这两个参数是配合使用的缺一个都会在导出 CSV 时留下nan字符串。engineopenpyxl显式指定解析器避免机器上装了旧版 xlrd 时读到.xlsx直接抛异常。清洗完先别急着合并做一次列名对齐检查set(df.columns) - set(EXPECTED_COLS)把多余列打出来。实际数据里经常多出一两列备注直接入库会因为列数不匹配整批失败。3.2 区间号段展开与冲突去重原始表里的号段有两种表达方式单值比如622202区间比如622202-622204表示这一段都归同一家机构。数据库里存区间很别扭查询端还得做数值比较最省事的做法是在清洗阶段就展开成一行一个号段。def expand_bin(cell: str) - list[str]: 把 622202-622204 展开成三个单值单值原样返回非法值返回空列表 cell cell.strip() if - not in cell: return [cell] if cell.isdigit() else [] lo, hi [p.strip() for p in cell.split(-, 1)] # 长度不一致、含非数字、区间倒挂一律丢弃交给人工复核 if not (lo.isdigit() and hi.isdigit()) or len(lo) ! len(hi): return [] # 防御性上限脏数据里出现过 1-99999999 这种展开会炸内存 if int(hi) int(lo) or int(hi) - int(lo) 10000: return [] width len(lo) return [str(n).zfill(width) for n in range(int(lo), int(hi) 1)]区间上限 10000 是血泪经验。某次直接跑展开遇到一行写成62-629999999的脏数据机器内存瞬间被吃满。加上限之后这类行会被丢进待复核清单而不是把整个流程拖死。zfill(width)保留原始位宽避免 6 位区间展开后变成不带前导的短串。展开完做两级过滤只保留长度在 6 到 8 之间的记录然后按(bin, bin_len)去重。rows [] for _, r in df.iterrows(): for b in expand_bin(r[bin]): rows.append({**r.to_dict(), bin: b, bin_len: len(b)}) out pd.DataFrame(rows) out out[out[bin_len].between(6, 8)] out out.drop_duplicates(subset[bin, bin_len], keepfirst) # 同一个号段对应多个机构名通常是历史更名或并购单独导出来人工判 conflict ( out.groupby([bin, bin_len])[issuer_name] .nunique() .loc[lambda s: s 1] ) conflict.to_csv(bin_conflict.csv, encodingutf-8)冲突清单不要自动取第一条了事。号段归属变更往往意味着某家机构被合并旧卡仍在流通业务上可能需要保留历史归属这时候就得靠人工确认哪条是最新口径或者干脆加版本字段。3.3 导出 CSV 的四个硬约定Excel 只是中转站真正喂给数据库的是 CSV。这份 CSV 有四条不能破的约定破一条后面就得返工。约定要求破了会怎样编码UTF-8 无 BOMBOM 会粘在第一列列名上导入报「未知列」换行\nWindows 换行在部分客户端会留下尾部\r污染最后一列表头小写下划线与建表列名一致列名不一致只能手写字段映射容易错位空值空串而非NULL字面量字符串NULL会被当真实值写进去out.to_csv( card_bin.csv, indexFalse, encodingutf-8, # 不要 utf-8-sigBOM 会坑死 LOAD DATA lineterminator\n, # 显式指定不跟随操作系统 )字段内容里如果出现英文逗号机构名带英文后缀的情况不少导出前统一替换成全角逗号或者导入时启用OPTIONALLY ENCLOSED BY 。两种做法都行但必须在导入脚本里体现出来不能靠猜。4. 落到 MySQL 和 PostgreSQL建表、导入与最长前缀匹配4.1 MySQL 表结构为什么 bin 必须是 varchar建表这一步决定了后面所有查询的天花板。核心表只有一张字段不多但每个类型选择都有理由。CREATE TABLE card_bin ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, bin VARCHAR(8) NOT NULL COMMENT 发卡行识别码纯数字 6~8 位, bin_len TINYINT UNSIGNED NOT NULL COMMENT 号段有效长度 6/7/8, card_org VARCHAR(16) NOT NULL DEFAULT DOMESTIC, card_type VARCHAR(16) NOT NULL DEFAULT DEBIT, card_level VARCHAR(16) NOT NULL DEFAULT , issuer_name VARCHAR(128) NOT NULL COMMENT 发卡机构名称, region_code CHAR(2) NOT NULL DEFAULT CN, status TINYINT NOT NULL DEFAULT 1, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_bin_len (bin, bin_len), KEY idx_len_bin (bin_len, bin) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;bin用VARCHAR(8)而不是整型前面说过是为了让LIKE 622202%和IN (...)都能正常走索引。bin_len冗余存一份长度一方面给ORDER BY bin_len DESC提供排序键另一方面参与唯一约束防止同一号段被重复导入。uk_bin_len这个联合唯一索引同时充当了bin的前缀查询索引不用再单独给bin建一个。排序规则这里有个细节utf8mb4_0900_ai_ci对纯数字没有影响但如果后面要在issuer_name上做去重或模糊搜索ai_ci会把大小写和重音都折叠掉反而不容易发现「同一家机构两种写法」。查机构名时建议显式加COLLATE utf8mb4_bin。4.2 LOAD DATA 批量导入与字段兜底几万到十几万行的号段表用INSERT拼 SQL 是给自己找麻烦直接走LOAD DATA。LOAD DATA LOCAL INFILE /data/card_bin.csv INTO TABLE card_bin CHARACTER SET utf8mb4 FIELDS TERMINATED BY , OPTIONALLY ENCLOSED BY LINES TERMINATED BY \n IGNORE 1 LINES (bin, bin_len, card_org, card_type, card_level, issuer_name, region_code, status) SET bin TRIM(bin), bin_len CHAR_LENGTH(TRIM(bin)), card_org IFNULL(NULLIF(TRIM(card_org), ), DOMESTIC), status IFNULL(NULLIF(TRIM(status), ), 1);SET子句里最值得抄的是bin_len CHAR_LENGTH(TRIM(bin))。CSV 里的长度列是人工维护的改过号段忘了改长度的情况太常见与其信它不如按实际值重算。NULLIF(x, )把空串转成 NULL外层IFNULL再兜一个默认值这样空列不会变成字符串或数字0写进库。LOCAL关键字要求服务端和客户端两头都开# 服务端my.cnf 里加 local_infileON或临时开 mysql -u root -p -e SET GLOBAL local_infile 1; # 客户端导入 mysql --local-infile1 -h 127.0.0.1 -u bin_reader -p carddb load_bin.sql导完必须验一次行数和抽样SELECT COUNT(*) AS total, SUM(bin_len 6) AS len6, SUM(bin_len 8) AS len8, SUM(bin REGEXP ^[0-9]$) AS all_digit FROM card_bin;all_digit如果小于total说明 CSV 里混进了带字母或空格的号段回到清洗脚本查expand_bin的返回值。4.3 PostgreSQLCOPY 与前缀索引的操作符类PG 这边的建表语句和 MySQL 差别不大重点在两个地方自增用bigserial或GENERATED时间用timestamptz。CREATE TABLE card_bin ( id bigserial PRIMARY KEY, bin varchar(8) NOT NULL, bin_len smallint NOT NULL CHECK (bin_len BETWEEN 6 AND 8), card_org varchar(16) NOT NULL DEFAULT DOMESTIC, card_type varchar(16) NOT NULL DEFAULT DEBIT, card_level varchar(16) NOT NULL DEFAULT , issuer_name varchar(128) NOT NULL, region_code char(2) NOT NULL DEFAULT CN, status smallint NOT NULL DEFAULT 1, updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT uk_bin_len UNIQUE (bin, bin_len) ); -- 这一行是 PG 上做前缀 LIKE 的关键少了它查询会退化成顺序扫描 CREATE INDEX idx_bin_prefix ON card_bin (bin varchar_pattern_ops);varchar_pattern_ops这个操作符类的作用是让 B-tree 索引按照「逐字节」的顺序比较而不是按数据库默认的本地化排序规则比较。默认排序规则下LIKE 622202%这种模式匹配无法映射成索引区间扫描只能全表扫。加了pattern_ops之后前缀条件才能变成索引范围查询。数据量小的时候感觉不出来上了几十万行以上差别就是几十毫秒和几秒。导入用\copypsql -h 127.0.0.1 -U bin_reader -d carddb -c \copy card_bin (bin, bin_len, card_org, card_type, card_level, issuer_name, region_code, status) FROM /data/card_bin.csv WITH (FORMAT csv, HEADER true, ENCODING UTF8, NULL )这里有个特别容易搞混的点\copy是 psql 客户端命令文件路径是执行 psql 的那台机器上的路径而COPY是服务端命令路径是数据库服务器上的路径。用远程连接时如果写成COPY ... FROM /data/card_bin.csv会报文件不存在因为文件在你本地不在服务器上。4.4 最长前缀匹配的两段 SQL 与执行计划查询接口通常只拿到一个完整卡号需要返回唯一的发卡机构。核心思路是构造 8 位、7 位、6 位三个候选前缀命中后按长度倒序取第一条。MySQL 版本-- :card_no 为传入的卡号这里用 6222021234567890 举例 SELECT bin, bin_len, issuer_name, card_type, card_level FROM card_bin WHERE status 1 AND bin IN ( LEFT(6222021234567890, 8), LEFT(6222021234567890, 7), LEFT(6222021234567890, 6) ) ORDER BY bin_len DESC LIMIT 1;PostgreSQL 版本语义一致只是数组写法更顺SELECT bin, bin_len, issuer_name, card_type, card_level FROM card_bin WHERE status 1 AND bin ANY ( ARRAY[left(6222021234567890, 8), left(6222021234567890, 7), left(6222021234567890, 6)] ) ORDER BY bin_len DESC LIMIT 1;两段 SQL 都依赖两点bin上有可用的索引ORDER BY bin_len DESC在命中行数极少最多 3 行时开销可忽略。如果写成 6 位优先或者漏了ORDER BY拿到的可能是父前缀那条发卡机构就错了——这种错还很隐蔽因为返回的确实是一个合法机构名。两个库的差异集中在一张表里更直观维度MySQL 8 系PostgreSQL 12 以上批量导入LOAD DATA [LOCAL] INFILECOPY服务端/\copy客户端前缀索引普通 B-tree 即可需varchar_pattern_ops操作符类空串处理默认存空串默认存空串NULL 可转 NULL自增AUTO_INCREMENTbigserial或GENERATED AS IDENTITY时间类型DATETIMEtimestamptz带时区如果团队同时维护两套库建议把建表语句和导入脚本都放进版本库字段口径以本文那张字段表为准别让两边各改各的。5. 避坑与排查五条现场血泪记录数据本身不难难的是从 Excel 到数据库这条路上每一步都有安静的失败模式不报错但结果是错的。下面五条都是实际遇到过、并且排查花了时间的按「现象 → 原因 → 解决」写清楚。5.1 导入后 6 位号段一条都查不到现象表里明明有 6 位号段用WHERE bin 622202查不到WHERE bin 622202反而能查到。原因建表时把bin定义成了INT或BIGINT。整型列上做字符串比较会触发隐式转换MySQL 会把列值转成数字再比表面看能查出结果但一旦查询条件带LIKE或者前导零行为就完全不可控。更麻烦的是索引在这种转换下失效。解决bin一律用VARCHAR。已经建错表的用ALTER TABLE ... MODIFY COLUMN bin VARCHAR(8)改类型改完重建索引然后跑一遍全量校验确认没有值被截断。5.2 用 Excel 打开 CSV 再保存号段全变成科学计数现象CSV 导入报错或者导进去的bin变成了6.22202E05这种值长度校验大面积失败。原因双击 CSV 时 Excel 会按内容自动推断类型19 位卡号段或纯数字列被当成数值超过 11 位就转科学计数法再保存回去时精度已经丢了。这个过程不可逆。解决不要用 Excel 打开待导入的 CSV。如果必须看用「数据 → 从文本/CSV」导入并显式指定所有列为文本。更彻底的办法是让 CSV 全程只在脚本和数据库之间流转人工核对用单独的 Excel 文件两条链路分开。5.3 同一张卡查出两个发卡行现象查询接口偶尔返回两个不同机构或者同一批卡号在测试环境和生产环境结果不一致。原因匹配逻辑写成了 6 位优先或者虽然查了三个前缀但漏了ORDER BY bin_len DESC。当 8 位号段和它的 6 位父前缀同时存在于表里时LIMIT 1拿到的顺序由存储引擎决定不稳定。解决查询语句强制ORDER BY bin_len DESC并且在数据侧加一条约束——UNIQUE (bin, bin_len)之外导入后跑一次检查找出「存在 8 位记录同时存在其 6 位父前缀且归属不同」的组合逐条确认父前缀那条是不是历史遗留。5.4 PostgreSQL 上加了索引前缀查询还是全表扫现象EXPLAIN显示Seq Scan表数据量上来之后单次查询几百毫秒。原因只建了普通 B-tree 索引(bin)没有指定varchar_pattern_ops。在非 C 排序规则下普通索引无法支撑LIKE xxx%的区间扫描。解决补建CREATE INDEX idx_bin_prefix ON card_bin (bin varchar_pattern_ops);然后ANALYZE card_bin;让优化器重新收集统计信息。如果查询条件里带status 1可以考虑建复合索引(status, bin varchar_pattern_ops)但要注意列顺序选择性低的列放前面反而可能拖慢。5.5 号段归属改了历史对账对不上现象月初更新了一版号段表月底对账发现某批历史交易的发卡机构名和当时记录的不一致。原因表里只保留最新快照机构更名或被并购后直接覆盖旧值没有生效时间和版本概念。历史交易当时记的是旧名称用新表回查自然对不上。解决给表加effective_from和effective_to两个日期字段更新时不覆盖而是插入新版本并把旧版本effective_to置为变更日期。查询时按业务时间点过滤WHERE effective_from :txn_date AND (effective_to IS NULL OR effective_to :txn_date)。这套改造会牺牲一点查询简洁度但对账类系统属于必须。6. 把 BIN 查询收成一层服务外加一个体检脚本号段表进了库下一步通常是被多个服务调用支付路由要判断卡组织风控要按卡种走不同规则客服系统要展示发卡行。与其每个服务各写一遍 SQL不如收成一层薄薄的内存索引服务把最长前缀回退的逻辑固化下来。思路很简单启动时把status 1的记录全量拉进一个字典键是(bin, bin_len)查询时按 8、7、6 顺序三次get。号段表规模在十万行量级全量加载占用的内存完全可以接受换来的是零数据库往返。import pymysql from functools import lru_cache LEVELS (8, 7, 6) def load_index(conn) - dict: 把有效号段全量读进内存键为 (bin, bin_len) idx {} with conn.cursor(pymysql.cursors.DictCursor) as cur: cur.execute( SELECT bin, bin_len, issuer_name, card_type, card_level FROM card_bin WHERE status 1 ) for r in cur.fetchall(): idx[(r[bin], r[bin_len])] r return idx class BinResolver: def __init__(self, index: dict): self.index index lru_cache(maxsize100_000) def resolve(self, card_no: str): card_no card_no.strip() if not card_no.isdigit() or not 12 len(card_no) 19: return None for n in LEVELS: hit self.index.get((card_no[:n], n)) if hit: return hit return Nonelru_cache在这里不是为了省数据库查询是为了省切片和字典查找的开销——同一批卡号在对账场景里会反复出现缓存命中率通常不低。缓存键直接用卡号本身注意这里只是匹配归属不做任何持久化。配套的体检脚本每次更新数据后跑一遍比事后排查便宜得多def audit(index: dict) - dict: bad_len, bad_digit, dup [], [], [] seen set() for (bin_val, bin_len), row in index.items(): if len(bin_val) ! bin_len: # 长度字段与实际不符 bad_len.append((bin_val, bin_len)) if not bin_val.isdigit(): # 混入非数字 bad_digit.append(bin_val) if (bin_val, bin_len) in seen: # 唯一键冲突 dup.append((bin_val, bin_len)) seen.add((bin_val, bin_len)) return {bad_len: bad_len, bad_digit: bad_digit, dup: dup}三类问题里bad_len最值得盯。它通常不是导入出错而是源数据里人工改号段忘了改长度或者在区间展开时位宽没对齐。这类记录不会导致查询报错只会让最长前缀回退的判断失准属于典型的静默错误。早期我图省事更新完数据只看一眼总行数就上线结果有一次 8 位号段被截成 7 位所有落在那一段的卡全被识别成父前缀的机构问题在对账环节才暴露。从那以后每次更新号段表我都会强制跑一遍audit再抽样比对几家机构的归属这个习惯替我省了不止一次返工。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑