资讯动态

银联卡BIN表构建与查询:号段、最长前缀匹配与命中率验证

发布时间:2026/10/26 9:14:56 来源:尧图企业网站定制
简介这份「2025最新银联卡BIN表」面向支付系统开发、数据分析、风控规则配置及接口测试人员提供按银行维度整理的银联卡BIN参考数据可用于卡BIN查询、发卡行识别、卡类型判断、卡号长度校验以及支付路由初筛等场景。压缩包内共1个文件主体为JSON格式体积约49KB结构精简、字段直观便于直接解析、导入数据库或接入测试脚本减少手工整理和格式转换成本。数据字段包含银行名称、卡BIN、卡类型和卡号长度适合用于表单校验、测试数据构造、风控规则验证及业务系统联调时的基础数据核对也可为卡BIN查询接口、发卡行匹配和卡种标识补全等功能开发提供轻量参考。已有1592人学习下载适合需要快速获取结构化银联卡BIN数据并用于开发、测试或分析的读者。1. 一次拒付复盘把我拽回了银联卡 BIN 表上个月有笔线上交易卡号前六位看着没毛病路由却把一笔境内借记卡甩给了境外通道多绕一圈清算手续费和到账时间都翻倍。排查到后半夜问题不在网关也不在风控规则而在手里那份半年没更新的银联卡 BIN 表——62 开头的新号段没进库最长前缀匹配落到了一个更老、更粗的区间上。BINBank Identification Number标准里现在叫 IIN就是卡号前 6 到 8 位它决定这张卡谁发的、借记还是信用、能不能走某条通道。所谓“2025 最新”重点不在某份快照而在刷新机制能不能跟上号段变化。这篇讲这份表怎么建、怎么查、怎么验证它没骗你。2. 六位还是八位银联卡 BIN 的号段结构与字段拆解把 BIN 表当成一张“号段到属性”的映射表这个理解只对了一半。真正决定它好不好用的是位宽口径、字段粒度和校验强度这三件事有没有在入库前定死。这三件事只要有一件含糊后面查询侧写得再漂亮都是白搭——我见过太多团队在 SQL 和缓存上反复优化最后发现是入库时把 8 位前缀截成了 6 位。2.1 IIN 的位宽迁移与 62 开头的由来按 ISO/IEC 7812 的定义卡号由发卡行识别号IIN早期叫 BIN、个人账户标识和校验位组成。早期 IIN 固定 6 位后来标准把 IIN 的最大长度放宽到 8 位目的是缓解号段耗尽——6 位十进制只有一百万种组合全球发卡机构一多就不够分。这个变化对做表的人意味着什么意味着你不能假设“所有 BIN 都是 6 位”也不能假设“所有卡号都是 16 位”。银联标准卡的卡号最常见的是 62 开头长度 16 位或 19 位。这里要区分两个概念很多人第一次做这块都会混MII主行业标识卡号第一位标识发卡行业。6 在 ISO/IEC 7812 的划分里对应由境内卡组织分配的号段。卡组织品牌由 BIN 段决定同一张卡可能同时带有境内品牌和境外品牌的标识双标卡。所以一份能用的表至少要能回答三个问题这个前缀属于哪个品牌、这个品牌下是借记还是信用、这张卡允许的卡号长度区间是多少。缺了第三项你连“卡号长度合法性”都判不了只能靠 Luhn 兜底而 Luhn 对长度是不敏感的。还有一个容易被忽略的点号段是会迁移的。发卡机构调整业务、卡产品下线、号段回收再分配都会让一份表在几个月内出现偏差。这就是为什么“2025 最新”这种表述在工程上应该被翻译成“带更新时间的、可追溯来源的号段集合”而不是一个静态文件。2.2 一条 BIN 记录该有哪些字段字段设计决定了后续所有查询和统计的上限。下面这张表是我目前用的结构字段不算多但每一项都有明确用途字段类型用途示例值仅示意结构非权威数据bin_len小整数该记录的有效前缀位数6 或 88bin_start整数号段起始统一补齐到 8 位后存整数62220000bin_end整数号段结束单号段时等于 bin_start62229999brand字符串卡组织品牌UNIONPAYcard_type字符串借记 / 信用 / 预付DEBITcard_level字符串普卡 / 金卡 / 白金 / 钻石PLATINUMissuer_name字符串发卡机构名称内部脱敏代号即可ISSUER_Acountry_code两位字符发卡地区CNpan_len_min / pan_len_max小整数该号段允许的卡号长度区间16 / 19source字符串数据来源标识用于冲突消解OFFICIAL_2025Q1updated_at时间戳记录最后更新时间—几个设计取舍值得说明。号段用整数区间而不是字符串前缀是因为区间可以用二分查找字符串前缀只能靠字典树或者 LIKE前者在百万级数据上性能差一个量级。bin_len 单独存一列是为了让查询侧能明确地“从长到短”匹配而不是靠猜。issuer_name 用内部代号一方面避免把外部机构名写进业务库另一方面发卡机构改名时不用动全表。提示上表里的示例值只是为了说明字段形态不要拿去做任何真实判定。任何一份可用的号段集合都必须来自你自己核验过的来源。2.3 用 Luhn 和号段自检把脏数据挡在入库前BIN 表最常见的脏数据有两类卡号本身不合法录入错误、OCR 错位号段边界写反或者写重叠。前者用 Luhn 校验能挡掉绝大部分后者要在入库前做区间自检。这两个检查加起来不到五十行代码但能省掉后面几周的排查。def luhn_ok(pan: str) - bool: Luhn 校验从右往左偶数位翻倍大于 9 减 9总和能被 10 整除 if not pan.isdigit() or not (12 len(pan) 19): return False total 0 for i, ch in enumerate(reversed(pan)): d ord(ch) - 48 if i % 2 1: # 从右数第 2、4、6... 位翻倍 d * 2 if d 9: d - 9 total d return total % 10 0 def check_range(bin_start: int, bin_end: int, bin_len: int, pan_len_min: int, pan_len_max: int) - list: 号段自检返回问题列表空列表表示通过 problems [] if bin_len not in (6, 8): problems.append(fbin_len 非法: {bin_len}) upper 10 ** 8 - 1 if not (0 bin_start bin_end upper): problems.append(f区间非法: {bin_start}-{bin_end}) # 号段长度必须一致否则补齐到 8 位后边界会错位 if len(str(bin_start)) ! bin_len or len(str(bin_end)) ! bin_len: problems.append(号段位数与 bin_len 不一致) if not (12 pan_len_min pan_len_max 19): problems.append(f卡号长度区间非法: {pan_len_min}-{pan_len_max}) return problemsluhn_ok里最容易写错的是翻倍位的起点。标准做法是从右往左数第 2、4、6…位即索引为奇数的位翻倍翻倍后大于 9 就减 9。写成从左往右翻倍16 位卡号刚好也能过——因为 16 是偶数对称但 19 位卡号就会全错这个坑我在测试环境踩过一次线上跑了两天才被发现。check_range里那条“号段位数与 bin_len 一致”的检查看着多余其实很关键。如果入库时把一条 6 位号段存进了 8 位组bin_start会变成622200这种量级和 8 位组的62220000完全不在一个数值空间里二分查找会静默地返回 None而不是报错。3. 从零攒一份可用的银联卡 BIN 表来源、清洗、建表、导入字段定下来之后接下来是数据从哪里来、怎么洗、怎么落库。这一段是整个工程里最耗人力、最没有技术含量、但也最不能省的部分。3.1 数据来源分级与合规边界按可靠度我给数据来源分四级优先级从高到低自有交易沉淀自己系统里跑过的卡去掉卡号中间部分只留 BIN 段这是最贴合业务的数据也是唯一能反映“你的用户到底在用哪些号段”的数据。卡组织公开的号段公告与规范文档用于校准品牌归属和号段边界。发卡机构公开的材料用于补 card_type 和 card_level覆盖度有限但准确度高。商业号段数据授权覆盖最广但需要逐条抽样验证且必须确认授权范围允许你的使用场景。这里有一条硬边界不落完整卡号。BIN 表只需要卡号前 8 位工程上从采集环节就只截取前缀日志、消息队列、临时文件里都不应该出现完整 PAN。卡号属于敏感数据行业里通用的做法是“前端采集即截断、传输即掩码、存储只留 BIN 后四位”。我见过有团队为了“方便核对”在清洗脚本里把原始卡号打到 stdout结果被日志采集系统原样收走后面清理花了很大力气。另外一点关于来源的判断标准能说清楚某一条号段是怎么来的比“覆盖了多少万条”重要得多。一条来源不明的号段如果被误判成信用卡可能让一批借记卡用户走到分期风控里这个错误比少几条数据严重得多。3.2 清洗规则区间合并、冲突消解、长度约束原始数据到手先做三件事顺序不能乱。第一件统一补齐到 8 位再比较。6 位号段622200补齐成62220000–62229999代表它覆盖的 8 位空间。不补齐的话6 位组和 8 位组没法比较重叠。第二件同属性相邻区间合并。如果两条记录的 brand、card_type、card_level、issuer 完全一致且区间首尾相接或重叠合并成一条。合并能显著减少记录数也能减少查询时的边界判断。第三件冲突消解。不同来源对同一段号给出不同属性时按“来源优先级 → updated_at 新者优先 → 区间窄者优先”排序取第一条被覆盖的那条写入一张冲突表留档不要直接删。def merge_same_attr(rows: list) - list: 按 bin_len 分组组内按 start 排序属性相同且相邻/重叠的区间合并 rows sorted(rows, keylambda r: (r[bin_len], r[bin_start], r[bin_end])) merged [] for r in rows: last merged[-1] if merged else None same_attr last and all( last[k] r[k] for k in (bin_len, brand, card_type, card_level, issuer_name) ) # 相邻判定起始 上一条结束 1 if same_attr and r[bin_start] last[bin_end] 1: last[bin_end] max(last[bin_end], r[bin_end]) else: merged.append(dict(r)) return merged这段代码有两个细节值得说。排序键里带了bin_end是为了让包含关系大区间包小区间先处理大区间否则会出现小区间先入列、大区间来了反而被切成两条的情况。last[bin_end] 1这个相邻判断只在补齐到 8 位的整数空间里成立如果你混着 6 位和 8 位一起排序这个加一会让 6 位组的尾部误判成相邻所以必须先按bin_len分组。清洗完一定要出一份统计总记录数、按 bin_len 分布、按 brand 分布、冲突条数、被合并条数。这份统计是后面验证的基础没有它你没法判断一次刷新到底是“数据变多了”还是“某条合并规则出错了”。3.3 建表与批量导入表结构要同时支撑“按前缀精确定位”和“按区间扫描”两种查法前者用于在线查询后者用于离线对账。下面是我用的 DDLCREATE TABLE card_bin ( id BIGSERIAL PRIMARY KEY, bin_len SMALLINT NOT NULL CHECK (bin_len IN (6, 8)), bin_start BIGINT NOT NULL, bin_end BIGINT NOT NULL, brand VARCHAR(16) NOT NULL, card_type VARCHAR(16), card_level VARCHAR(16), issuer_name VARCHAR(64), country_code CHAR(2), pan_len_min SMALLINT NOT NULL DEFAULT 12, pan_len_max SMALLINT NOT NULL DEFAULT 19, source VARCHAR(64) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT ck_bin_range CHECK (bin_end bin_start), CONSTRAINT ck_bin_len CHECK ( (bin_len 8 AND bin_start BETWEEN 0 AND 99999999) OR (bin_len 6 AND bin_start BETWEEN 0 AND 999999) ) ); -- 在线查询走这个索引先按位数过滤再按起始值二分 CREATE INDEX idx_card_bin_len_start ON card_bin (bin_len, bin_start, bin_end); -- 同号段唯一防止重复导入 CREATE UNIQUE INDEX uk_card_bin_range ON card_bin (bin_len, bin_start, bin_end);ck_bin_len这条约束看着啰嗦但它挡住的是最隐蔽的一类错误把 6 位号段误存成 8 位组的数值。有了它入库阶段就会直接报错而不是等到查询时静默返回空。导入脚本用execute_values批量写不要逐条INSERT——十万级数据逐条提交光是网络往返就能把导入时间拉到小时级。import csv import psycopg2 from psycopg2.extras import execute_values def load_csv(path: str, conn) - int: rows [] with open(path, newline, encodingutf-8) as f: for r in csv.DictReader(f): prefix r[bin_prefix].strip() bin_len len(prefix) # 先取原始位数再补齐 pad 8 - bin_len start int(prefix.ljust(8, 0)) end_raw r.get(bin_prefix_end) or prefix end int(end_raw.ljust(8, 0)) (10 ** pad - 1) # 6 位号段展开成 8 位区间 rows.append(( bin_len, start, end, r[brand], r.get(card_type), r.get(card_level), r.get(issuer_name), r.get(country_code), int(r.get(pan_len_min) or 12), int(r.get(pan_len_max) or 19), r[source], )) with conn.cursor() as cur: execute_values(cur, INSERT INTO card_bin (bin_len, bin_start, bin_end, brand, card_type, card_level, issuer_name, country_code, pan_len_min, pan_len_max, source) VALUES %s ON CONFLICT (bin_len, bin_start, bin_end) DO UPDATE SET brand EXCLUDED.brand, card_type EXCLUDED.card_type, card_level EXCLUDED.card_level, issuer_name EXCLUDED.issuer_name, source EXCLUDED.source, updated_at now() , rows, page_size1000) conn.commit() return len(rows)关键在10 ** pad - 1这一项6 位号段622200代表的是从62220000到62229999这一万个 8 位前缀展开后存成[62220000, 62229999]才能和 8 位组在同一数值空间里比较。如果只存[62220000, 62220000]那这个号段只能命中一个 8 位前缀其余九千九百九十九个全落空。page_size1000是个经验值再大容易顶到单条 SQL 的参数上限再小提交次数太多。导入完记得跑一次ANALYZE card_bin让规划器拿到最新统计否则刚导入的数据上索引选择可能不理想。4. 查询侧怎么写才扛得住并发最长前缀匹配的三种实现表建好了接下来是最考验设计的一步给定一个卡号怎么在毫秒级返回它的属性而且结果要确定。这里的关键词是最长前缀优先——8 位组有命中就用 8 位的没有才降级到 6 位组绝不能反过来。4.1 线性扫、区间二分、前缀树的选择三种实现我都写过各有明确适用场景实现单次查询复杂度内存占用适用规模主要问题线性扫O(n)最低千条以内十万条时单次查询到毫秒级以上区间二分O(log n)低十万到百万级需要保证区间不重叠且有序前缀树O(位数)高每节点指针开销大百万级以上且查询极频繁构建慢内存容易失控我的选择是区间二分。原因是 BIN 数据有个天然特性同一个 bin_len 组内清洗后的区间基本不重叠这正好是二分查找的前提。前缀树虽然理论复杂度更低但 8 层深度的树在百万级记录下内存占用会很明显而且每次刷新都要全量重建运维成本不划算。区间二分的实现有一个必须注意的点排序键和查找键必须属于同一组。8 位组和 6 位组分开建索引不能混在一个数组里二分。import bisect class BinIndex: def __init__(self, rows: list): self.groups {} for r in rows: self.groups.setdefault(r[bin_len], []).append(r) self.starts {} for n, g in self.groups.items(): g.sort(keylambda x: x[bin_start]) self.starts[n] [x[bin_start] for x in g] def find(self, pan: str): if not pan.isdigit() or len(pan) 6: return None for n in sorted(self.groups, reverseTrue): # 8 位先查6 位后查 if len(pan) n: continue key int(pan[:n]) g self.groups[n] i bisect.bisect_right(self.starts[n], key) - 1 if i 0 and g[i][bin_start] key g[i][bin_end]: return g[i] return Nonesorted(self.groups, reverseTrue)保证了最长前缀优先这个顺序如果被人“优化”成字典遍历顺序8 位组可能永远轮不到。bisect_right(...) - 1拿到的是最后一个起始值小于等于 key 的区间再判一次上界就完成了一次 O(log n) 的区间命中判断。4.2 Redis 缓存键设计与命中率BIN 数据的更新频率是天级甚至周级查询频率是每秒数千次以上天然适合放缓存。缓存键的设计要同时满足“最长前缀优先”和“空结果也能缓存”两个要求import json import random def resolve(pan: str, cache, index, ttl: int 86400) - dict: pan pan.strip().replace( , ) for n in (8, 6): if len(pan) n: continue key fbin:{n}:{pan[:n]} raw cache.get(key) if raw is not None: return json.loads(raw) hit index.find(pan[:n]) # 单组查找不再降级 payload hit or {brand: UNKNOWN, bin_len: n} # 空结果也缓存TTL 加随机抖动避免整点大量 key 同时失效 cache.setex(key, ttl random.randint(0, 3600), json.dumps(payload)) if hit: return payload return {brand: UNKNOWN, bin_len: None}这里有三个设计决定。缓存空结果是为了防穿透未知号段如果每次都不缓存一批异常卡号能把数据库打穿。TTL 加随机抖动是为了防雪崩所有 key 同时失效的瞬间压测表现会很难看。缓存键里带位数是为了让 8 位组和 6 位组的缓存互不干扰刷新 8 位数据时不用整体 flush。命中率要分层统计至少分成“8 位命中”“6 位降级命中”“完全未命中”三档。只看总命中率会掩盖问题如果 6 位降级命中占比突然从 5% 涨到 30%说明 8 位数据没刷新而这个信号在总命中率上可能完全看不出来。4.3 批量查询与降级真实的调用场景很少是单卡查询更多是批量对账或者风控规则跑批。批量场景下逐条走缓存会让网络往返成为瓶颈常见的做法是批量取、批量回填def resolve_batch(pans: list, cache, index, chunk: int 500) - list: result [] for i in range(0, len(pans), chunk): part pans[i:i chunk] keys [fbin:8:{p[:8]} for p in part if len(p) 8] cached dict(zip(keys, cache.mget(keys))) if keys else {} for p in part: k fbin:8:{p[:8]} if cached.get(k): result.append(json.loads(cached[k])) continue hit index.find(p) if hit: cache.setex(k, 86400, json.dumps(hit)) result.append(hit) else: result.append({brand: UNKNOWN, bin_len: None}) return result降级策略要提前定好不要等出事再想。我的做法是查询失败或未命中一律返回 UNKNOWN不阻断主流程。支付链路里 BIN 查询只是路由的一个输入因子缺这个因子顶多是少了一条优化路径但如果因为查不到就抛异常一笔本来能过的交易就被拦下了。未命中本身是好数据要落日志、按天聚合聚合结果直接驱动下一次表刷新。5. 银联卡 BIN 表上线后最容易翻车的五个地方逐条排查这一章全是踩坑记录。每一条都是我在测试或者线上真实遇到过的现象、原因、解决按这个顺序写方便你对照排查。5.1 双标卡被当成境外卡境内交易被拒现象一批以 4 或 5 开头的卡在境内通道被拒但同样的卡在境外通道能过。用户投诉集中在“偶尔刷不了”很难复现。原因把“卡组织品牌”和“受理通道”当成了同一个字段。双标卡的卡号前缀可能属于某个境外品牌但它同时具备境内清算能力。如果表里只存了一个 brand 字段路由层拿 brand 直接决定走哪条通道这批卡就会被推到错误的链路上。解决拆成两个字段brand表示卡号归属的品牌channel表示这张卡支持的受理通道可以是多值。路由层只读 channel不读 brand。这个改动量不大但要在表结构层面先留出位置事后再加列会很痛苦。5.2 六位和八位混存最长前缀静默失效现象新发的 8 位号段卡识别结果落到一个老的发卡机构上而且没有任何报错。原因查询侧先查了 6 位组。6 位组的区间通过补齐展开后覆盖范围很大62xxxx这种粗区间几乎能吃掉所有 62 开头的卡8 位组永远轮不到。解决查询循环改成for n in sorted(groups, reverseTrue)8 位优先。同时在入库阶段加一条断言同一个 8 位前缀不能同时落在两个不同属性的大区间里导入时就报错。这类问题最难受的地方是它不报错只能靠监控里的“降级命中率”发现。5.3 区间重叠同一张卡两次查询两个结果现象压测时同一批卡号查出来的结果偶发不一致重启服务后恢复正常。原因表里存在重叠区间数据库返回顺序不确定。查WHERE bin_start ? AND bin_end ?时如果没有ORDER BY返回哪一条取决于执行计划。解决三层防护。入库时用唯一索引挡住完全重复的区间查询时加ORDER BY bin_len DESC, bin_end - bin_start ASC LIMIT 1位数长的优先区间窄的优先内存索引构建时如果检测到同组内重叠启动直接失败把这变成启动期问题而不是运行期问题。5.4 日志里躺着完整卡号现象安全扫描在应用日志里发现了疑似卡号的字符串追下去定位到 BIN 查询的 debug 日志。原因排查问题时为了省事在入口处打了个logger.debug(resolve %s, pan)本来只打算开一天结果忘了删然后日志级别被调到 DEBUG 跑了两个月。解决在日志框架层面做统一脱敏匹配到 13 到 19 位连续数字就保留前 6 后 4中间打码。这一层比在几百个业务点上人工注意要可靠。另外BIN 查询这个入口本身就不该接收完整卡号能只传前 8 位就只传前 8 位从源头减少暴露面。5.5 虚拟卡号段让未命中率突然抬头现象某个渠道接进来之后BIN 未命中率从 1% 出头涨到 8% 左右但交易成功率没明显下降。原因一次性虚拟卡号的号段不在公开数据里而且这类卡号的生命周期很短靠离线表根本追不上。全量拦掉是错的因为它们大部分是合法交易。解决给未命中单独设计兜底策略——卡号长度合法、Luhn 通过、前缀在已知的宽松范围内就放行到下一层风控做判断而不是在 BIN 这一步直接拒。未命中的卡号按前缀聚合每天看一次如果某个前缀的未命中量持续增长再单独去核这个来源。6. 用命中率和影子比对给这份表做体检一份 BIN 表最危险的状态不是“有错误”而是“看起来没问题”。它不像接口挂了会报警号段错了只会让一小部分交易悄悄走错路等财务对账发现差异往往已经过去一个月。所以这张表必须有自己的体检机制我用的是两个指标加一道流程。先看指标口径。下面这几个是我每天都会扫一眼的指标计算口径预警线处置动作8 位命中率8 位命中数 / 有效查询数日环比下降 10%检查最近一次刷新是否漏批降级命中率6 位命中数 / 有效查询数超过 15%8 位数据覆盖不足排期补采完全未命中率未命中数 / 有效查询数超过 3%按前缀聚合抽样核查单前缀未命中突增某前缀未命中数日环比涨幅超过 2 倍判断是否新号段或虚拟卡影子差异率新旧表结果不一致数 / 比对量超过 0.5%暂停切流人工复核影子比对是切流前的最后一道关。做法很简单新表上线前把线上流量复制一份到新表做查询结果不返回给业务只做比对。差异按字段拆开统计——brand 差异、card_type 差异、card_level 差异分开看因为它们的严重程度完全不同。brand 差异通常意味着路由会走错必须清零card_level 差异可能只是权益分层不准可以容忍一部分。def shadow_diff(old_index, new_index, pans: list) - dict: 影子比对只统计差异不改变任何线上行为 stat {total: 0, brand_diff: 0, type_diff: 0, level_diff: 0, samples: []} for p in pans: o, n old_index.find(p), new_index.find(p) stat[total] 1 of, nf o or {}, n or {} if of.get(brand) ! nf.get(brand): stat[brand_diff] 1 if len(stat[samples]) 20: stat[samples].append({pan_prefix: p[:8], old: of, new: nf}) elif of.get(card_type) ! nf.get(card_type): stat[type_diff] 1 elif of.get(card_level) ! nf.get(card_level): stat[level_diff] 1 return statsamples只留 20 条是为了控制日志体积但前缀要完整保留 8 位否则复核时定位不到具体号段。这个函数在比对 10 万条样本时耗时大概几十秒放在离线任务里跑完全够用。最后是一点个人习惯。我给这份表定了个规矩每次刷新必须同时产出三样东西——变更清单、冲突表、刷新人签字。变更清单说明这次新增和修改了哪些号段冲突表记录哪些号段被覆盖以及为什么签字是让某个人对“这次数据可以上线”负责。听起来有点重但我吃过一次亏某次刷新有人把一批境外号段误标成了境内品牌因为没人复核错误在库里躺了三周最后是靠一笔跨境交易的费率异常才翻出来。从那以后我就认一个理BIN 表这种“错了也不报警”的数据靠流程比靠代码管用。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑