简介:面向支付系统开发、金融数据分析等场景的银行卡BIN数据包,源自银联官方2020年4月25日发布的最新最全银行卡BIN信息,覆盖非标卡、农民工卡、跨行转账卡、单位结算卡及普通卡等各类卡组织。资源共9868条记录,完整包含BIN码、BIN长度、发卡行、银行卡名称、银行卡类型、卡号长度等核心字段,且已整理为可直接导入MySQL的SQL脚本,免去手工清洗与转换的麻烦。压缩包仅1.12MB,共7个文件,其中5个Excel按卡种分表存放,便于分类查阅,另附SQL文件用于快速建库加载。当前已有3490人下载学习。对于需要权威卡BIN做支付路由、风控识别或数据分析的开发者而言,这份官方口径的数据能直接作为基础库使用,按需查询或批量比对都很方便。
1. 银行卡BIN数据是什么:给卡号做“户籍识别”的地基
做支付系统的人迟早会发现:区分一张卡是哪个银行发的、是借记卡还是贷记卡,最快的方式不是去查开户行,而是直接拿卡号前六位去匹配“银行卡bin数据(Excel+MySQL)-2020最新最全-银联官方发布”这类整理好的BIN索引。我第一次拿到这份数据时,用本地查询替换了某个收费且响应慢的第三方识别接口,把一次支付接口调用的耗时从几百毫秒压到个位数毫秒,同时省掉了按次计费的成本。这类数据集在圈子里流通很广,核心价值只有一个:输入卡号,立刻返回发卡行、卡种、卡类型。做支付接口、写风控规则、补交易报表维度的开发者,都应该在本地维护一份可查询的BIN数据,而不是把每一次判断都交给外部接口。
2. 读懂BIN表:字段口径、卡号规则与两种格式的差异
2.1 BIN的划分逻辑:为什么说“前6位”只是一个起点
银行卡BIN(Bank Identification Number)是卡号前段用于标识发卡机构的部分。早期标准下,6位BIN足够区分发卡行,但这些年卡组织陆续启用8位BIN来支撑更多发卡机构和产品线。国内很多新发行的卡仍然是6位BIN为主,但你在设计表结构和查询逻辑时,最好从一开始就兼容8位,否则后续维护会很被动。
BIN数据解决的是“卡是谁发的”,而卡号本身是否合法,靠的是Luhn算法。Luhn只能验证卡号是否符合生成规则,不能证明卡片真实存在。在业务里一般先做Luhn校验,过滤明显乱写的卡号,再拿BIN去匹配发卡行。这个先后顺序能省掉大量无意义的BIN查询。
def luhn_ok(card_no: str) -> bool: """校验银行卡号的Luhn算法,合法返回True""" if not card_no.isdigit(): return False digits = [int(c) for c in card_no] # 从右往左数,偶数位乘2,结果大于9就减9 for i in range(len(digits) - 2, -1, -2): d = digits[i] * 2 digits[i] = d - 9 if d > 9 else d return sum(digits) % 10 == 0上面这个函数按标准Luhn实现,入参是完整卡号字符串。业务里我一般把它放在BIN查询之前:先丢给这个函数,连Luhn都过不了就直接返回“卡号非法”,不再走BIN表。参数说明里唯一要留意的是入参必须传字符串,不能传整数,否则卡号以0开头时会被Python自动丢掉前导位。
2.2 一张能直接上线的BIN表该有哪些字段
市面上流通的BIN表字段大同小异,但落地的时候不能拿到就用。我建议至少包含以下字段,并额外加上数据版本和有效期,方便后面排查线上问题。
| 字段 | 类型建议 | 必填 | 说明 |
|---|---|---|---|
| bin_no | CHAR(6) | 是 | 6位BIN号,文本类型存储 |
| bin_no_8 | CHAR(8) | 否 | 8位BIN号,为空时用6位匹配 |
| issuer_name | VARCHAR(64) | 是 | 发卡行标准名称 |
| card_product | VARCHAR(32) | 否 | 卡种名,如标准借记卡、联名卡 |
| card_kind | TINYINT | 是 | 1借记 2贷记 3准贷记 9未知 |
| card_org | VARCHAR(16) | 是 | 卡组织标识 |
| province | VARCHAR(16) | 否 | 发卡省份,可用于区域风控 |
| eff_date | DATE | 否 | 该BIN生效日期 |
| exp_date | DATE | 否 | 失效日期,NULL表示长期有效 |
| data_version | VARCHAR(16) | 是 | 数据文件版本,如202001 |
注意两件事:第一,bin_no和bin_no_8都必须用CHAR,不能用INT,原因后文避坑章节会详细讲;第二,card_kind不要用中文枚举值直接落库,用TINYINT加注释,查询时再关联字典,这样索引效率和扩展性都好得多。
2.3 Excel版和MySQL版:先想清楚自己拿来干什么
标题里同时给了两种格式,这是这类数据包的常见形态。Excel版适合人工核对,比如你要向审计解释某条识别结果的依据,打开Excel筛选一下很快;MySQL版适合直接进业务库联表查询,省去转换步骤。我的习惯是两个都要:Excel留档,MySQL进测试库先验证,确认没问题再上生产。
两份数据的一致性值得花半小时验一下。最常见的问题是Excel导成MySQL时某些单元格被当作日期或数字处理,导致BIN号变形、卡种漏值。验证办法很朴素:先数MySQL里的总行数对不对,再抽几个特定发卡行的BIN去Excel里反向查找。如果连总数都能对不上,后面的查询逻辑做得再花哨也没有意义。
3. 把Excel版导入MySQL:从建表到数据校验的一次性落地
3.1 先建表:字段类型错了,导入再快也是白干
导入之前先建表。下面是我常用的建表语句,拆开来看每一条都有目的:bin相关字段用CHAR而不是VARCHAR,因为BIN长度固定,CHAR查询更快;card_kind用TINYINT而非字符串,压缩存储空间;索引只建在查询最频繁的bin_no上,不要给每个字段都加索引,否则导入慢、占用大。
CREATE TABLE card_bin ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, bin_no CHAR(6) NOT NULL COMMENT '6位BIN,文本存储防丢前导零', bin_no_8 CHAR(8) DEFAULT NULL COMMENT '8位BIN扩展', issuer_name VARCHAR(64) NOT NULL COMMENT '发卡行名称', card_product VARCHAR(32) DEFAULT NULL COMMENT '卡种名', card_kind TINYINT NOT NULL COMMENT '1借记 2贷记 3准贷记 9未知', card_org VARCHAR(16) NOT NULL COMMENT '卡组织标识', province VARCHAR(16) DEFAULT NULL COMMENT '发卡省份', eff_date DATE DEFAULT NULL COMMENT '生效日期', exp_date DATE DEFAULT NULL COMMENT '失效日期,NULL长期有效', data_version VARCHAR(16) NOT NULL COMMENT '数据版本号', UNIQUE KEY uk_bin (bin_no, bin_no_8), KEY idx_bin6 (bin_no), KEY idx_issuer (issuer_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='银行卡BIN表';UNIQUE KEY建在(bin_no, bin_no_8)上,是因为同一张卡可能同时有6位BIN和8位BIN两行记录,单纯给bin_no建唯一索引会造成误判,导入中途报错。MySQL版本在8.0以下时,utf8mb4的索引长度限制要留意,好在这个表字段都不长,不会触发。
3.2 用Python把Excel批量灌进MySQL:最小脚本
建完表就直接灌数据。pandas读Excel很快,但有一个坑几乎必踩:纯数字列会被自动读成int64,导致以0开头的BIN号丢失前导零。所以读文件时必须显式指定dtype为str。
import pandas as pd import pymysql # 按实际Excel列名调整这里的映射 COLUMN_MAP = { 'BIN': 'bin_no', 'BIN8': 'bin_no_8', '发卡行': 'issuer_name', '卡种': 'card_product', '卡类型': 'card_kind', '卡组织': 'card_org', '省份': 'province', '生效日期': 'eff_date', '失效日期': 'exp_date', '版本': 'data_version', } def load_bin_excel(path: str) -> pd.DataFrame: # dtype=str 防止BIN被读成int而丢失前导零 df = pd.read_excel(path, dtype=str) df = df.rename(columns=COLUMN_MAP) # 统一空值:pandas的NaN要让MySQL认成NULL df = df.where(pd.notna(df), None) return df def import_to_mysql(df: pd.DataFrame, conn) -> int: records = df.to_dict('records') sql = """ INSERT INTO card_bin (bin_no, bin_no_8, issuer_name, card_product, card_kind, card_org, province, eff_date, exp_date, data_version) VALUES (%(bin_no)s, %(bin_no_8)s, %(issuer_name)s, %(card_product)s, %(card_kind)s, %(card_org)s, %(province)s, %(eff_date)s, %(exp_date)s, %(data_version)s) """ cursor = conn.cursor() cursor.executemany(sql, records) conn.commit() return cursor.rowcount if __name__ == '__main__': df = load_bin_excel('银行卡BIN表_2020.xlsx') conn = pymysql.connect( host='127.0.0.1', user='root', password='change_me', database='paydb', charset='utf8mb4' ) total = import_to_mysql(df, conn) print(f'导入完成,共 {total} 行') conn.close()脚本里最关键的两个参数:一是read_excel的dtype=str,这是保住BIN号前导零的底线;二是pymysql连接里要带charset='utf8mb4',否则中文发卡行名称在部分MySQL配置下会变成乱码。executemany批量插入比逐行execute快一个数量级,几万行数据通常几秒就导完。如果Excel里有合并单元格,pandas读进来会出现NaN,脚本里的where(pd.notna(df), None)就是处理这个的。
3.3 导入后必做的三项校验:数据有没有废,一眼看出来
导入完成别急着接业务。先用四条SQL做完整性检查,全部通过再往下一层走。
-- 1. 总数核对:和Excel行数对不上就是导入丢了行 SELECT COUNT(*) FROM card_bin; -- 2. 重复BIN检查:同一BIN出现多次,说明源文件或合并逻辑有问题 SELECT bin_no, COUNT(*) AS cnt FROM card_bin GROUP BY bin_no HAVING cnt > 1 LIMIT 10; -- 3. 卡类型取值检查:值不在1/2/3/9范围内就是洗数据时出了错 SELECT card_kind, COUNT(*) AS cnt FROM card_bin GROUP BY card_kind; -- 4. 抽样核对:选定几个发卡行,人工去Excel里反查 SELECT bin_no, issuer_name, card_product, card_kind FROM card_bin WHERE issuer_name IN ('某城商行', '某股份制银行') LIMIT 20;第一、二条是数量维度,第三、四条是质量维度。实际踩过的坑是:源Excel里同一BIN对应多个卡种,直接导入后GROUP BY会出现重复行,如果不加处理,线上查询时LIMIT 1取到的可能不是想要的卡种。所以第四条抽样核对里,我通常会顺带看一眼相同bin_no下是否有不同card_kind。
4. BIN数据在业务里的四个典型用法:从“能查”到“好用”
4.1 秒级识别:输入卡号返回发卡行与卡种
最基础也最常用的场景。拿到一个完整卡号,先Luhn校验,再按BIN匹配。
-- 业务层传入完整卡号,优先匹配8位BIN,取不到再退6位 SELECT issuer_name, card_product, card_kind FROM card_bin WHERE bin_no_8 = LEFT('6228480402564890018', 8) OR (bin_no_8 IS NULL AND bin_no = LEFT('6228480402564890018', 6)) LIMIT 1;这里用LEFT截取卡号前段,配合两个条件的OR优先级要注意:bin_no_8有值的先走8位匹配,为空的行走6位匹配。单独用WHERE bin_no = LEFT(card_no, 6)也能跑通,但会把8位BIN的卡也归到6位旧数据上,识别结果可能是错的。LIMIT 1是为了防止同BIN多卡种时返回多行,至于取哪一行,取决于前面建表时UNIQUE KEY的设计。
4.2 风控规则:把“拒绝贷记卡”变成一条SQL
反欺诈风控里,BIN最常见的用途是判断卡种。比如某些大额优惠活动只允许借记卡参加,风控规则需要把贷记卡挡在门外。
-- 判断卡种:返回1表示是借记卡,非1则拦截 SELECT card_kind FROM card_bin WHERE bin_no = LEFT('6228480402564890018', 6) LIMIT 1;落地的做法是在下单或支付接口里加一个前置判断,先查BIN表拿card_kind,不是借记卡直接返回风控拦截码。这里值得多说一句:card_kind字段如果源数据里就有缺失,查询会返回NULL,代码里一定要用if kind != 1而不是if kind == 2来判断,否则NULL值会让所有缺失卡种全部通过风控,线上出大事。
4.3 通道路由:不同BIN走不同支付通道
有些支付场景要按发卡行分流。比如某通道对某股份制银行的卡费率低,对其它行费率一般,路由系统就可以在发起支付前先查BIN,命中指定发卡行就走优惠通道,否则走默认通道。
SELECT issuer_name, card_org FROM card_bin WHERE bin_no = LEFT('6217003810023456789', 6) LIMIT 1;这个用法不复杂,但有一个配置上的建议:把“哪些发卡行走哪个通道”的映射关系放在配置中心,不要写死在代码里,因为通道费率经常变。BIN表负责告诉你是谁,路由配置负责决定你去哪,两者职责分开,后维护的人才不容易打架。
4.4 交易报表:用BIN表补全分析维度
交易流水表里通常只有card_no,没有发卡行维度。要做“各发卡行交易金额排行”,最省事的方式是和BIN表做关联补充。
SELECT b.issuer_name, COUNT(t.id) AS tx_cnt, SUM(t.amount) AS tx_amt FROM txn t LEFT JOIN card_bin b ON t.card_no LIKE CONCAT(b.bin_no, '%') GROUP BY b.issuer_name ORDER BY tx_amt DESC;这里用了LIKE关联,在报表场景问题不大,但在高频交易表上应避免。更稳的做法是应用层先解析出card_no对应的BIN,再拿BIN去精确匹配。报表跑一次无所谓,接口每次都LIKE扫描会让数据库CPU迅速拉满,属于典型的“能用但不好用”的写法。
5. BIN数据落地避坑:最容易翻车的五个现场
5.1 坑一:把2020版“最新最全”当成永远够用的数据
现象:新发行的银行卡在系统里识别不出发卡行,返回“未知银行”。
原因:银行卡BIN是持续增发的。每年都有新卡种上线,有些城商行合并重组后发卡行名称也会变化。标题里的“2020最新最全”在当年靠谱,但在今天看,一定存在覆盖不到的新BIN。
解决:不要把BIN数据当成静态表。入库时保存data_version,每季度做一次增量对比,把新增BIN合入现网表。如果业务对时效敏感,可以给查询接口增加“未命中时转第三方查询”的兜底逻辑,既保留本地性能,又保证新卡可识别。
5.2 坑二:BIN字段用了INT类型,前导零被MySQL吃掉
现象:所有以0开头的BIN匹配全部失败,但纯数字的BIN又正常。
原因:这是最经典的翻车现场。Excel里的BIN看起来是数字,导入MySQL时如果表字段建的是INT,前导零会被自动丢弃,比如实际BIN是“012345”,入库后变成“12345”。线上查询用“012345”去匹配,永远匹配不到。
解决:建表字段统一用CHAR(6)或CHAR(8),导入脚本里指定dtype=str。已经在库里踩坑的,先把列类型改成CHAR,再用LPAD函数把丢失的前导零补回来。血泪经验:任何卡号、BIN、证件号,一律按文本处理,不要存成整数。
5.3 坑三:只用6位匹配,撞上8位BIN的卡
现象:某些卡识别出的发卡行和卡面印的银行不一致,但不是全部卡都错。
原因:8位BIN的卡用6位也能匹配到一条旧数据,但那条旧数据可能是同一机构早期申请的6位区间,归属到具体卡种时会错位。8位BIN是卡组织为解决6位号码不够用而推出的扩展,发行时间较晚的卡更容易命中这个坑。
解决:表结构里预留bin_no_8字段,查询逻辑改成“先8位后6位”,只有当bin_no_8为空时才退化到6位匹配。源数据里如果没有8位BIN列,至少要在匹配逻辑上保留扩展位,方便后续补数。
5.4 坑四:只存发卡行,不存卡种和卡组织
现象:风控想拦截贷记卡,但BIN表里查不到卡种,只能放行或全部拦截;通道想区分卡组织也做不到。
原因:拿到数据后只保留了自己当时最关注的字段,删掉了看似用不到的列。等业务提新需求时,原数据包已经不知道丢到哪里去了。
解决:哪怕暂时用不到,也把card_kind、card_org、province这些字段原样入库。多占不了多少空间,但将来某个风控或运营需求落地时,你不会为了一张旧表重新找数据源、重新清洗。
5.5 坑五:Excel里有合并单元格或空行,直接导入导致数据错位
现象:导入的总行数比Excel实际数据行数少几百行,或某行的发卡行串到了上一行。
原因:部分表格为了好看,发卡行列做了单元格合并,pandas读出来之后只有第一行有值,后面全为空。直接用这样的DataFrame入库,大量行的issuer_name就是NULL,抽样核对时才发现。
解决:读文件后先做空值检查,对合并单元格的情况用ffill()向下填充,再执行导入。填充前也要确认填充方向正确,免得把表头也填进去。
# 发卡行列做向下填充,解决合并单元格导致的空值 df['issuer_name'] = df['issuer_name'].ffill()6. 让BIN库长期不废:三个低成本维护习惯
第一,每次拿到新版本BIN数据,先导入一张bin_new临时表,和现网表做对比,输出新增、失效、变更三类差异后再决定是否更新现网表。对比SQL用NOT EXISTS就能实现,不复杂,但能避免“整表覆盖后旧卡全查不到”的惨剧。
-- 找出新增BIN SELECT bin_no FROM bin_new n WHERE NOT EXISTS (SELECT 1 FROM card_bin o WHERE o.bin_no = n.bin_no);第二,维护一组固定卡号作为回归用例。选十几张不同发卡行、不同卡种的测试卡号,写进一个配置文件,每次更新BIN数据后批量跑一遍查询,确认识别结果和更新前一致。这个习惯救过我一次:某次更新后某城商行的借记卡全被识别成贷记卡,就是靠回归用例第一时间发现的。第三,把data_version写进业务日志。生产环境出现识别异常时,先看日志里的版本号,能立刻判断是数据问题还是代码问题,不用靠猜。
我现在每个月第一件事就是跑一遍BIN差异对账,几分钟的事,但能保证线上不会拿着过期数据硬扛。这套方案投入很小,胜在稳定可预期,希望帮到你。
本文还有配套的精品资源,点击获取