简介:全国邮编区号数据集合集,适合需要批量获取市县区邮编与电话区号的开发者、数据分析师及运营人员。数据涵盖全国3423条市县区记录,包含市、县、区名称、区号及邮编字段,可满足地理信息匹配、地址库建设、快递物流系统开发、CRM客户信息整理等场景。整包共4个文件,提供SQL、JSON、CSV、Excel四种主流格式,SQL便于直接导入数据库,JSON和CSV适合程序读取与数据交换,Excel便于人工查阅筛选,压缩包大小约167KB。目前已有951人学习下载。资源字段结构清晰、无冗余信息,下载后可按需选用对应格式,省去自行爬取和清洗数据的麻烦,能帮助快速落地全国区号邮编数据调用需求,对地理相关的业务系统开发尤为实用。
1. 全国邮编区号大全(数据库):一张离线表为什么比在线接口更省心
做订单系统的时候,我第一个踩的坑不是代码,而是一份数据。全国邮编区号大全(数据库)听起来像一次性的静态文件,但它决定了地址校验准不准、区号能不能自动带出、订单能不能按区域自动分仓。我接过一个电商后台的需求,地址解析原本调在线接口,晚高峰经常超时,一个月光接口费就烧掉不少,最后靠本地落库解决。
把数据落成本地表后,查询延迟降到毫秒级,成本趋近于零。顺着标题往下拆:表结构怎么定、数据源怎么选、导入与查询怎么做、业务侧怎么接,以及必须提前知道的坑。
适合谁读:电商、CRM、物流系统要做地址标准化又不想依赖外部接口的团队,以及网络隔离、数据敏感场景。按结构设计、落库、接入、避坑、维护的顺序往下走。
2. 表结构设计与数据源选型:字段怎么定才不会返工
一份静态数据库最容易翻车的地方不是数据量,而是字段设计。拿到 CSV 后第一件事不是导入,而是先想清楚这张表要支撑哪些查询:按邮编反查地址、按城市带出区号、按区县匹配订单地址。字段定不好,后面每一次接入都在为当初的偷懒买单。
2.1 核心字段怎么定:一张宽表覆盖所有常见查询
我一般把邮编、区号、行政区划平铺在同一张宽表里,而不是拆成城市表和邮编表两张再关联。原因很简单:这份数据的查询模式固定,宽表可以直接走索引回表,少一次 JOIN 就少一层心智负担。
CREATE TABLE postcode_db ( id INT AUTO_INCREMENT PRIMARY KEY, province_code CHAR(6) COMMENT '省级行政区划代码', province_name VARCHAR(50) COMMENT '省/自治区/直辖市', city_name VARCHAR(50) COMMENT '市/自治州/地区', district_name VARCHAR(50) COMMENT '区/县/县级市', postal_code CHAR(6) COMMENT '邮政编码', area_code VARCHAR(4) COMMENT '长途区号,不含前导0', postal_level TINYINT COMMENT '投递级别:1省级、2市级、3区县级', update_date DATE COMMENT '数据源发布日期', version VARCHAR(20) COMMENT '数据包版本号', KEY idx_postal_code (postal_code), KEY idx_city_name (city_name), KEY idx_district_name (district_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有几个参数值得说清楚。postal_code用 CHAR(6) 而不是 VARCHAR,因为邮编固定 6 位,定长字段索引更紧凑,避免 VARCHAR 的长度前缀计算开销。area_code特意注释“不含前导0”,这是后面最容易埋雷的地方——有人存 010、有人存 10,混在一起必然出 bug,后面避坑章节会展开讲。postal_level平时看着没用,但它是排查重复邮编记录的救命线,同一邮编可能对应多个投递段,没有这个字段你将分不清哪条记录才是对的。
version和update_date不是业务必需,但强烈建议保留。没有版本号,上线后出了数据问题,你连“现在跑的到底是哪一版”都不知道。
2.2 数据源交叉验证:统计口径做骨架、邮政口径做内容
邮编数据通常有两个来源:邮政部门维护的邮编库,和统计部门发布的行政区划编码。这两个口径更新节奏不一样。撤县设区、地市合并这类调整发生之后,统计口径往往先更新,邮编库的投递段还要滞后一阵子。常见做法是以统计口径的行政区划为骨架,以邮政口径的邮编为内容,两个源交叉比对,不一致的记录单独标记人工复核。
我用的最笨也最有效的方法,是把两个源分别导进临时表,用省市区三级名称做关联键,然后跑一条差集查询:
SELECT a.district_name, a.postal_code, b.postal_code AS postal_code_b FROM tmp_stat a LEFT JOIN tmp_post b ON a.province_name = b.province_name AND a.city_name = b.city_name AND a.district_name = b.district_name WHERE a.postal_code <> b.postal_code;这条 SQL 的逻辑是:以统计口径为左表,找出邮编不一致的所有记录。查出来的结果分两类。一类是区划代码已更新、邮编没跟上,属于正常滞后,以邮政口径为准;另一类是两个源根本对不上,比如某新区已经成立但邮编库还没有相应投递段,这类必须人工确认,不能直接合并。
区号这边要注意,电话区号按电信网络规划分配,跟行政区划层级并非严格对齐。有的地级市多个县共用一个区号,有的区号覆盖两个行政区域。所以区号字段只适合做“输入区号带出大致城市”这种粗粒度功能,不要试图用它反推精确行政区划。
2.3 三种交付格式怎么选:SQL、CSV、JSON 各自的使用边界
数据包常见三种交付格式:SQL 脚本、CSV、JSON。我的选择标准是:后端落库用 SQL 或 CSV,前端校验用 JSON,手工维护用 CSV。
| 格式 | 适用场景 | 注意点 |
|---|---|---|
| SQL 脚本 | 一次性初始化、MySQL 环境 | 换用 PostgreSQL / SQLite 要改语法 |
| CSV | 跨库迁移、手工维护 | 编码和分隔符必须提前确认 |
| JSON | 前端字典、小数据量 | 不适合后端聚合查询 |
SQL 脚本把建表和插入打包在一起,导入最省事,但如果你要落 PostgreSQL,MySQL 方言的字段类型(比如TINYINT、ENGINE=InnoDB)全要改。CSV 是中间格式,任何数据库都能接,代价是字段类型、字符集、分隔符都得自己控制。JSON 适合给前端直接消费,但等你要按城市分组统计时,用 JSON 当存储就是给自己找麻烦。
如果拿到的是 CSV,我会先用三条命令确认底细:
file city_postal.csv wc -l city_postal.csv head -3 city_postal.csvfile命令输出文件编码,CSV 常见 GBK 或 UTF-8,这一步能提前暴露后面最坑的乱码问题。wc -l看总行数,导入前后用来对账。head看表头和分隔符,确认列顺序和建表字段一致,避免整行错位。
3. 灌进 MySQL 并跑通查询:建表、导入命令与索引设置
数据源到手的下一步就是落库。这一章把从建表到对账的完整链路走一遍,每一步标出哪些参数可以按实际情况改。
3.1 建表语句:字符集、主键和索引一起定好
沿用第 2 章的结构,实际建表时两处细节一定要盯住:字符集用utf8mb4而不是utf8,因为地名里可能存在生僻字和特殊符号,utf8在 MySQL 里最多存 3 字节,碰到 4 字节字符会直接报错或截断。排序规则用utf8mb4_unicode_ci,按 Unicode 算法排序,对中文地名更友好。
CREATE TABLE postcode_db ( id INT AUTO_INCREMENT PRIMARY KEY, province_code CHAR(6), province_name VARCHAR(50), city_name VARCHAR(50), district_name VARCHAR(50), postal_code CHAR(6), area_code VARCHAR(4), postal_level TINYINT, update_date DATE, version VARCHAR(20), KEY idx_postal_code (postal_code), KEY idx_city_name (city_name), KEY idx_district_name (district_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;索引不要贪多。邮编一个、城市名一个、区县名一个,基本覆盖全部查询场景。区号查询频率低,等真有需要再加,索引不是越多越好——写入变慢、占用空间,对一份要频繁更新的字典表来说尤其明显。
3.2 导入数据的两种姿势:LOAD DATA 与 source 脚本
拿到 CSV 之后我推荐优先用LOAD DATA,速度比逐条 INSERT 快一个量级,而且能直接指定字符集和分隔符:
LOAD DATA LOCAL INFILE '/data/city_postal.csv' INTO TABLE postcode_db CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' IGNORE 1 LINES (province_code, province_name, city_name, district_name, postal_code, area_code, postal_level, update_date, version);几个参数按实际情况改:CHARACTER SET utf8mb4要和建表字符集一致,如果 CSV 是 GBK,先转码再导,否则中文必乱。FIELDS TERMINATED BY ','对应分隔符,从业务系统导出的可能是制表符,改成'\t'即可。IGNORE 1 LINES跳过表头,文件没有表头就删掉这一行。
如果拿到的是已经生成好的 SQL 文件,直接在 mysql 客户端执行:
mysql -u root -p < postcode_full.sql这种方式把建表和插入一起跑完,适合一次性初始化。缺点是文件里如果包含重复插入语句,重跑会主键冲突,建议导入前先确认目标表是空的。
我有一次导入,文件是 GBK 编码但没指定字符集,入库后前 500 条全是问号,最后只能清空重导。重导之前先用file命令看一眼编码,这一步花不了十秒,能避免一次全量返工。
提示:导入前先确认编码,比导入后跑对账省时间。
3.3 三条必会查询与它们的索引命中情况
导入完成后,典型查询有三类。第一类,输入邮编反查地址和区号:
SELECT province_name, city_name, district_name, area_code FROM postcode_db WHERE postal_code = ?;第二类,输入城市名,查该城市所有区县的邮编和区号:
SELECT district_name, postal_code, area_code FROM postcode_db WHERE city_name = ?;第三类,输入不完整地址做模糊匹配,常用于订单地址补全:
SELECT postal_code, area_code FROM postcode_db WHERE district_name LIKE CONCAT('%', ?, '%') LIMIT 10;前两条走的是idx_postal_code和idx_city_name索引,等值查询稳定命中。第三条里的%前缀会导致索引失效——MySQL 的 B+ 树索引只支持前缀匹配,%关键词%必须全表扫描。几万行数据全表扫描也很快,可以接受;但等数据量涨到百万行,这条 SQL 就要改成先按关键词切分、再走等值索引的方案,具体做法在第 4 章。
3.4 导入后四条对账语句:数据缺不缺一眼看清
我每次导完数据都会跑下面这四条,不跑不敢往业务上接:
SELECT COUNT(*) FROM postcode_db; SELECT COUNT(*) FROM postcode_db WHERE postal_code IS NULL OR postal_code = ''; SELECT postal_code, COUNT(*) FROM postcode_db GROUP BY postal_code HAVING COUNT(*) > 1; SELECT COUNT(*) FROM postcode_db WHERE CHAR_LENGTH(postal_code) <> 6;第一条看总行数,和源文件wc -l的结果核对,数量差太多要怀疑导入时跳行或字段错位。第二条看邮编空值,正常应该是 0,有值说明源数据本身有缺陷。第三条看重复邮编,这里不用追求 0——同一邮编对应多个投递段是正常的,你要确认的是重复记录里postal_level有区分;如果同邮编同区县但区号不同,那就是数据错误,必须回源排查。第四条看邮编长度,CHAR_LENGTH按字符数计算,能筛出混进来的空格或非数字字符。
4. 业务侧接入的三种姿势:表单、订单解析和离线兜底
数据落库之后,真正决定这套东西值不值的是接入方式。我按使用频率排序,讲三种最常见的接法。
4.1 表单自动带出:前端 JSON 词典 + 500ms 防抖
下单页最常见的需求是用户输入邮编后自动带出区号,或者选中城市后自动填邮编。如果每次输入都回查后端接口,体验和费用都不划算。常见做法是把数据导出成精简 JSON 放到前端静态资源里。
先用一条命令从数据库导出 TSV:
mysql -u root -p -N -e \ "SELECT postal_code, area_code, province_name, city_name, district_name FROM postcode_db" \ > postal_dict.tsv-N表示不输出表头,方便后续处理。再写一个小脚本把 TSV 转成 JSON:
import json items = [] with open('postal_dict.tsv', encoding='utf-8') as f: for line in f: zip_code, area_code, province, city, district = line.strip().split('\t') items.append({ 'zip': zip_code, 'area': area_code, 'province': province, 'city': city, 'district': district }) with open('postal_dict.json', 'w', encoding='utf-8') as f: json.dump(items, f, ensure_ascii=False)ensure_ascii=False保证中文以明文写入,而不是转成\uXXXX转义序列,文件体积更小、可读性也更好。前端拿到这份 JSON 后,用防抖处理输入事件,避免每个字符都触发一次遍历:
let timer = null; const zipDict = window.POSTAL_DICT; function onZipInput(value) { clearTimeout(timer); timer = setTimeout(() => { const hit = zipDict.find(item => item.zip === value.trim()); if (hit) { document.querySelector('#area_code').value = hit.area; } }, 500); }这段逻辑的核心是防抖:用户连续输入“2”“23”“234”时,前三次都不会触发查询,只有停顿 500 毫秒后才执行一次。几万条数据的find每次遍历是毫秒级,配合防抖完全够用。如果数据量超过十万条,可以考虑把数组改成按邮编前两位分桶的哈希结构,就这份数据包的体量来说没必要。
4.2 订单地址解析:先切单位再回查数据库,命中率更高
订单系统里拿到的地址经常是“某省某市某区某街道 123 号”混成一整串。直接把整串地址拿去 LIKE 匹配数据库,有两个问题:一是子串误匹配,比如“某省东部”这种描述可能命中不相干的记录;二是地址越长,前缀索引越难发挥作用。
我一般把地址按省、市、区县的关键词字典先切分,再用切出来的区县名做等值查询:
def resolve_address(text): for level, names in level_names.items(): for name in names: if name in text: row = db.execute( "SELECT postal_code, area_code FROM postcode_db " "WHERE district_name = ? LIMIT 1", (name,) ).fetchone() if row: return {"postal_code": row[0], "area_code": row[1]} return None这段代码的思路是:把字典按省、市、区县分好,从长地名到短地名依次匹配。注意顺序必须是区县在前、省市在后,因为区县名往往包含在省市名里,先匹配省市会把“省”“市”切进去,导致区县失配。另一个关键是匹配到区县后直接等值查询,不再 LIKE,这样能稳定命中idx_district_name索引。这个方法对“地址里缺少区县名”的场景会失效,那时只能退回模糊匹配加人工兜底。
4.3 离线与内网环境:SQLite 只读库当兜底
很多生产环境不允许业务服务直连 MySQL,更常见的是内网部署、数据库账号权限收紧。我一般会把这份数据单独导出一个 SQLite 只读文件,让服务用本地文件查询,避免跨服务依赖。
sqlite3 postcode.db <<EOF CREATE TABLE postcode_db ( postal_code CHAR(6), area_code VARCHAR(4), province_name VARCHAR(50), city_name VARCHAR(50), district_name VARCHAR(50) ); .mode csv .import city_postal.csv postcode_db CREATE INDEX idx_postal_code ON postcode_db(postal_code); EOFSQLite 的导入用.import直接吃 CSV,.mode csv告诉它按逗号分隔。导入之后必须建索引,否则几万行数据全表扫描也会让人觉得卡。只读部署时把文件权限设为 444,多个进程同时读没有冲突。SQLite 方案的唯一坑是写入锁,但这份数据本来就是只读字典,完全用不上写入特性。
注意:同一邮编查出多条记录不一定是脏数据,先看投递级别再判断。
5. 避坑:邮编区号数据落库后的五个典型翻车现场
这份数据的坑不在导入本身,而在导入之后你以为一切正常。下面五条都是实际排查过的场景,按“现象 → 原因 → 解决”的顺序讲。很多问题初看像玄学,其实原因都很具体。
5.1 中文导入全变问号:字符集没对齐
刚导完数据,执行SELECT province_name FROM postcode_db LIMIT 5,返回的全是???。这就是编码没对齐,CSV 文件是 GBK 编码,而 LOAD DATA 指定的字符集是 utf8mb4,或者反过来。乱码在导入阶段不报错,到业务查询阶段才暴露,所以特别坑。
iconv -f GBK -t UTF-8 city_postal.csv > city_postal_utf8.csv转码后重新导入,再跑对账 SQL,重点看中文列是否恢复正常。这个坑我踩过一次之后养成习惯:任何 CSV 到手先file命令看编码,再决定要不要转码。
5.2 同一个邮编查出两条记录:投递口径在作怪
某市的一个邮编段被查出两条记录,区号还不一样。原因在于这份邮编数据把不同投递级别的记录混在一起——同一邮编既对应市级投递中心,又对应区县投递段,源数据里这两条记录的区号字段就不一致。
排查时先看投递级别:
SELECT postal_code, area_code, postal_level FROM postcode_db WHERE postal_code = '某市该段邮编';解决方法是按投递级别去重,优先保留区县级那条;如果源数据没有postal_level字段,就按行政区划的父子关系处理。判断标准是硬约束:同邮编同区县必须同区号。
5.3 区号带不带前导零:一个字段两种存法必出 bug
前端展示区号时,有的记录显示“010”,有的显示“10”,格式不统一。原因就是整合时没做归一化,不同数据源的区号字段有的带前导 0、有的不带。解决方法是导入后统一去掉前导 0,展示时才拼上:
UPDATE postcode_db SET area_code = TRIM(LEADING '0' FROM area_code) WHERE area_code LIKE '0%';这样存储口径唯一,业务层统一在格式化函数里拼 0。以后不管查询、导出还是写接口,都不会再出现一个字段两种格式的情况。
5.4 直辖市 city_name 全空:行政区划层级缺失
查询某直辖市下辖区县时,city_name要么是空,要么直接填了直辖市名称。原因是源数据的行政区划层级里,直辖市没有“地级市”这一层,字段直接给 NULL。解决方法是导入后执行一次回填:
UPDATE postcode_db SET city_name = province_name WHERE city_name IS NULL OR city_name = '';省直辖县级市同理,把city_name回填为省份名或空字符串,具体看业务展示需求。不处理的后果是前端按城市分组时,直辖市下面的区县全部落到“未知城市”分组,物流分仓逻辑跟着出错。
5.5 更新后越查越慢:索引统计信息没刷新
数据包更新方式是直接覆盖表,但没重建索引,或者更新后没执行ANALYZE TABLE。原因是 InnoDB 的索引统计信息还停留在旧数据上,优化器可能放弃索引走全表扫描,数据量从几万涨到几十万之后差异特别明显。
排查时用 EXPLAIN 看执行计划:
EXPLAIN SELECT district_name FROM postcode_db WHERE city_name = '某市';如果type是ALL就是全表扫描。解决方法是每次导入后固定执行:
ANALYZE TABLE postcode_db;再跑一遍第 3 章的对账四连查,确认行数、空值、重复、长度都正常再切换流量。这一步最容易被忽略,但影响最直接。
6. 让这份数据活起来:前端词典导出与版本化更新脚本
数据不是导入一次就结束,行政区划和投递段都在变。最后给一套自己一直在用的维护习惯。
6.1 导出前端词典:只导出表单需要的列、压缩体积
第 4 章导出 JSON 的脚本可以再收一下:只保留表单校验真正需要的五列,别把update_date、version塞进去,JSON 体积能小一半。导出后顺手压缩一次:
gzip -k postal_dict.json前端部署时直接引用.gz文件,现代浏览器会自动解压,流量成本几乎为零。字典文件放 CDN 并开启长缓存,更新时文件名带版本号,强迫浏览器拉新文件而不是用旧缓存。
6.2 版本化更新:备份、导入、回归三步走
更新数据的流程我总结成三步,缺一不可。第一步备份,先把现网表结构连同数据导出成 SQL 文件,防止新数据有问题找不到后悔药。第二步导入临时表,而不是直接覆盖正式表:
CREATE TABLE postcode_db_tmp LIKE postcode_db; LOAD DATA INFILE '/data/new_city_postal.csv' INTO TABLE postcode_db_tmp CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' IGNORE 1 LINES;先在临时表上跑第 3 章那四条对账语句,确认无误后,再在事务里清空正式表并插入:
START TRANSACTION; TRUNCATE postcode_db; INSERT INTO postcode_db SELECT * FROM postcode_db_tmp; COMMIT;最后的回归验证不能省:随机抽 20 个邮编,用新表和旧表分别查询对比结果。我不久前一次更新就因为临时表没跑对账,上线后发现某县城的邮编全变了,最后靠备份回滚。现在的习惯是更新脚本里强制带上对账和抽样对比,通不过就拒绝切换。
从收到数据到真正放心交给业务,完整链路就是:选字段、验数据源、落库、对账、接入、维护。每一步都在为后面省时间。这套流程我反复用了很多次,最深的教训是:不要相信任何下载下来就能直接用的数据包,必须先过一遍自己的校验规则,哪怕只是跑三条 COUNT。希望帮到你。
本文还有配套的精品资源,点击获取