简介:面向需要搭建全国行政区划基础数据的开发者,这份 MySQL 数据包提供省、市、区三级经纬度信息,可用于地区选择器、地址库、区域维度表和地图打点等场景。整包仅含 1 个 SQL 文件,压缩后约 127KB,体量轻便,导入 MySQL 后即可形成可查询的省市区经纬度表,适合后台系统、数据分析和地理相关项目作为基础字典使用。已有 2999 人学习下载,说明其在常见业务开发中具有较高的参考与复用价值。数据按省、市、区层级组织,便于与订单、门店、用户地址等业务表关联,也能减少手工整理行政区划与坐标的工作量;SQL 脚本形式让建表与数据初始化更直接,方便二次筛选、扩展字段或按需裁剪区域范围。省市区三级坐标还可用于配送范围判断、门店覆盖分析、用户地域分布统计等,也能作为地理编码的辅助参考,对需要快速建立地区维度表、避免重复采集整理的团队较为实用。
1. 一份省市区经纬度表,能省掉三天的对照表整理
做门店选址的同事某天丢过来一张 3000 行的门店表,要补上省、市、区、经度、纬度五个字段,还要按同城五公里筛竞品。第一反应是攒一张对照表慢慢填,三天工时打底,下次换个城市还得重来。更省事的做法是先拿到一份全国省市区经纬度 MySQL 数据包,建一张区域底座表常驻库中,后面所有地址相关的活都从这张表里查。这类资源通常包含省、市、区县三级(部分带街道)的名称、六位行政编码、上下级关系与中心点坐标,格式多为可直接导入的 SQL 或 CSV。
它的价值不在于“有一份数据”,而在于把地址这件事变成可 JOIN 的确定性字段:省市区下拉、地址解析、经纬度打点、区域分组统计、附近排序,全部落成一条 SQL 能解决的事。受众很明确——做电商收货地址、门店与网点管理、物流分单、地图打点展示的后端同学,以及手上有一堆脏地址要做清洗归一的数据岗。下面按表结构、校验、查询、避坑、版本化的顺序拆。
2. 表结构定稿:parent_id 加六位编码,索引一次建对
拿到包之后第一件事不是急着导,而是先定表结构。结构定歪了,后面每一条查询都在还债。这一章的取舍理由是成本:区划表的数据量很小(省市区三级大约三千出头,加到街道级也才几万行),所以完全不用为存储和性能做任何妥协,一切以「查得顺手、改得安全」为准。
2.1 三种层级存法的取舍
区划数据本质是一棵树,落库常见三种存法,差别很实在。
| 存法 | 结构 | 查子树 | 查祖先链 | 增删改 | 适合场景 |
|---|---|---|---|---|---|
| 邻接表 | 每行存 parent_code | 需递归或多次查询 | 需递归 | 简单 | 层级浅、变更少(首选) |
| 路径枚举 | 额外存990000/990100/990101 | LIKE '990100%'一次搞定 | 拆字符串 | 移动节点要批量改 | 层级固定、查询频繁 |
| 嵌套集 | 存 left/right 值 | 区间查询一次搞定 | 区间包含 | 任何插入都可能全表更新 | 几乎只读的目录树 |
区划表是「层级固定三层、几乎只读、偶尔小改」的典型,邻接表加编码前缀就够了:parent_code 负责父子关系,六位编码本身自带层级语义(省两位、市两位、区两位),需要查子树时直接拿编码前缀LIKE '99%',等于白送一个路径枚举的能力,不用额外字段。
血泪经验是别一上来就上嵌套集。区划数据每年都有撤县设区、改名、编码调整,嵌套集做一次插入就要重算受影响节点的左右值,维护脚本比业务代码还长,收益却只有那一次区间查询。
2.2 建表 DDL:字段类型、字符集、索引一次定死
CREATE TABLE `sys_region` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `code` CHAR(6) NOT NULL COMMENT '六位行政区划代码,定长不用 VARCHAR', `parent_code` CHAR(6) NOT NULL DEFAULT '000000' COMMENT '顶级节点填 000000 而不是 NULL,省掉一堆 IS NULL 判断', `level` TINYINT UNSIGNED NOT NULL COMMENT '1省 2市 3区县 4街道', `name` VARCHAR(64) NOT NULL COMMENT '标准名称', `short_name` VARCHAR(32) DEFAULT NULL COMMENT '去掉省/市/区/县后缀的简称,做展示用', `full_name` VARCHAR(128) DEFAULT NULL COMMENT '省市区拼接全称,做地址匹配用', `lng` DECIMAL(10,6) DEFAULT NULL COMMENT '经度,GCJ-02,东经为正', `lat` DECIMAL(10,6) DEFAULT NULL COMMENT '纬度,GCJ-02,北纬为正', `is_leaf` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否末级,联动下拉最后一层只查这个=1', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用,历史区划不删只置灰', `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`), KEY `idx_parent` (`parent_code`), KEY `idx_level_leaf` (`level`, `is_leaf`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;几个参数值得单独说明。code用CHAR(6)不用VARCHAR,因为编码永远 6 位定长,定长类型在索引里不存长度前缀,比较也更快。parent_code的顶级默认值给'000000'而不是NULL,是为了后面所有 JOIN 和递归都不用处理三值逻辑,WHERE parent_code = '000000'直接拿全部省份。
lng/lat用DECIMAL(10,6):整数部分 4 位够放 135 这种经度值,小数 6 位约等于 0.11 米的分辨率,对区县中心点这种精度需求绰绰有余。字符集必须utf8mb4,区县名里有生僻字,utf8(三字节)会直接插入失败或被截断。
注意:
utf8mb4_0900_ai_ci是 MySQL 8.0 才有的排序规则,5.7 上建表会报错,换成utf8mb4_general_ci即可。这一步在跨版本迁移时最容易翻车。
2.3 经纬度到底用 DECIMAL、FLOAT 还是 POINT
这是最容易被跳过、事后最难受的一个选择。
FLOAT单精度只有约 7 位有效十进制数字,而一个经度写成 116.407526 已经是 9 位有效数字,存进去会产生米级误差。做「同城五公里」这种粗筛看不出问题,一旦用来做精确的围栏判断或者坐标去重就会出玄学。DOUBLE精度够,占 8 字节,对三千行数据毫无压力,是性价比很高的选择。
DECIMAL(10,6)的好处是「所见即所得」:导出来是什么、查出来就是什么,不会出现116.40752599999999这种浮点尾巴,做数据比对和 diff 时省心。
POINT加SPATIAL INDEX是另一种思路,MySQL 8.0 支持带 SRID 的空间列和空间索引,附近搜索能走索引。但代价是:列必须NOT NULL才能建空间索引,而且 SRID 0 和 SRID 4326 的几何值不能直接比较,ST_Distance_Sphere用错 SRID 会直接抛错。如果只是几千行区县中心点,用DECIMAL存坐标加普通BTree索引配合边界框裁剪,性能完全够,没必要上空间类型。
我的做法是:主表存DECIMAL,需要空间检索时另建一张region_geo表存POINT SRID 4326 NOT NULL并建空间索引,两张表用code关联。读写分离,谁也不影响谁。
2.4 导入:LOAD DATA 与批量 INSERT 的差别在哪
数据包给的通常是 CSV 或 SQL 文件。CSV 走LOAD DATA,速度比逐条 INSERT 快一到两个数量级。
-- 服务端与客户端都要允许本地文件导入,否则报 3948 SET GLOBAL local_infile = 1; LOAD DATA LOCAL INFILE '/data/region.csv' INTO TABLE sys_region CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (code, parent_code, level, name, short_name, full_name, @lng, @lat) SET lng = NULLIF(@lng, ''), -- 源文件空坐标写成空串,不能直接塞 DECIMAL lat = NULLIF(@lat, ''), is_leaf = 0; -- 先全置 0,导入完再统一刷一次这段里最容易出问题的是SET那几行。源 CSV 的空值往往是空字符串而不是\N,直接映射到DECIMAL列会被当成 0 存进去,结果就是一堆区县躺在几内亚湾。用NULLIF(@lng,'')中转一道,空串才会变成真正的NULL。
IGNORE 1 LINES跳过表头。如果源文件带 BOM,第一列的列名会变成\ufeffcode,导致报「未知列」,用sed -i '1s/^\xEF\xBB\xBF//' region.csv先清掉。
导完之后刷一次is_leaf:
UPDATE sys_region c LEFT JOIN sys_region s ON s.parent_code = c.code SET c.is_leaf = (s.id IS NULL);逻辑是「没有子节点即为叶子」,一条语句把全表标记完。注意 MySQL 不允许在 UPDATE 的子查询里直接引用被更新的表,所以这里用LEFT JOIN自连接而不是NOT EXISTS子查询,写法更稳。数据量上万行时建议分批,按level逐层刷。
3. 数据校验三道关:编码断链、层级错位、坐标越界
导进去不等于能用。网上流传的区划数据包质量参差不齐,我见过最离谱的一份,市级节点缺了十几个,下拉选到一半变空白。所以导入之后必须跑一遍校验,把脏数据挡在业务之前。
3.1 六位编码的进位规则
GB/T 2260 的编码结构是 2+2+2:前两位省级、中间两位市级、后两位区县级。由此推出几条硬规则:
- 省级节点编码后四位必须全为
00; - 市级节点编码前两位与父级一致,后两位为
00; - 区县节点六位通常全非
00,且前四位与父级市级编码一致; - 直辖市的区县挂在「市辖区」这一层下面,所以它们的
parent_code不是XX0000,而是XX01**,直接按「前四位等于父级」去校验会误报。
最后这条是校验脚本最容易写错的地方。稳妥的做法是分两步:先校验「前两位一致」,再单独对level=2且名称含「市辖区」「县」的节点放行,人工确认。
另外要区分 6 位区划代码和 12 位统计用区划代码。后者是 6 位区划码加 6 位城乡分类码(111 主城区、112 城乡结合区、121 镇中心区、122 镇乡结合区、123 特殊区域、210 乡中心区、220 村庄),到村居级。如果数据包里给的是 12 位,落库前必须截取前 6 位,否则和业务表按code关联时全都对不上。
3.2 用 SQL 找出孤儿、断链与重复
三道校验,写成三条查询,跑完心里就有底了。
-- 第一关:孤儿节点,父级在表里找不到 SELECT c.code, c.name, c.parent_code FROM sys_region c LEFT JOIN sys_region p ON p.code = c.parent_code WHERE c.level > 1 AND p.id IS NULL; -- 第二关:编码与层级不自洽(省不是 00 结尾、市和父级不同源) SELECT code, name, level, parent_code FROM sys_region WHERE (level = 1 AND RIGHT(code, 4) <> '00') OR (level = 2 AND (RIGHT(code, 2) <> '00' OR LEFT(code, 2) <> LEFT(parent_code, 2))); -- 第三关:末级标记与实际子节点数不一致 SELECT c.code, c.name, c.is_leaf, COUNT(s.id) AS child_cnt FROM sys_region c LEFT JOIN sys_region s ON s.parent_code = c.code GROUP BY c.id, c.code, c.name, c.is_leaf HAVING (c.is_leaf = 0 AND child_cnt = 0) OR (c.is_leaf = 1 AND child_cnt > 0);第一关的LEFT JOIN ... IS NULL是标准的反连接写法,比NOT IN安全,因为NOT IN遇到子查询里的NULL会整体返回空集,是个经典陷阱。第二关把编码规则翻译成字符串函数,RIGHT(code,4)取后四位,LEFT(code,2)取前两位,直观且不需要额外列。第三关用GROUP BY聚合子节点数,专门抓「标记是末级但实际有下级」这种前后矛盾的数据。
跑出来通常不会全空。孤儿节点要么是数据包本身缺行,要么是父级编码写错了,前者只能找补,后者可以按前四位反查补齐:
-- 用编码前缀给孤儿节点找父级 UPDATE sys_region c JOIN sys_region p ON p.code = CONCAT(LEFT(c.code, 4), '00') AND p.level = 2 SET c.parent_code = p.code WHERE c.level = 3 AND NOT EXISTS (SELECT 1 FROM (SELECT code FROM sys_region) t WHERE t.code = c.parent_code);这里的NOT EXISTS套了一层派生表,是为了绕过「不能在子查询里引用被更新表」的限制。写法啰嗦,但能跑。
3.3 坐标越界与空值率
经纬度的问题比编码更隐蔽,因为越界数据不会报错,只会在页面上表现为一个点跑到国外去了。
-- 越界检测:先用一个宽松的国内范围把可疑行圈出来 SELECT code, name, level, lng, lat FROM sys_region WHERE lng IS NULL OR lat IS NULL OR lng NOT BETWEEN 73 AND 136 OR lat NOT BETWEEN 3 AND 54; -- 空值率,按层级看 SELECT level, COUNT(*) AS total, SUM(lng IS NULL OR lat IS NULL) AS null_cnt, ROUND(SUM(lng IS NULL OR lat IS NULL) / COUNT(*) * 100, 2) AS null_pct FROM sys_region GROUP BY level;范围用的是粗框(经度 73~136,纬度 3~54),覆盖范围比实际版图略宽,目的是「宁可漏报不要误杀」。真正落在框外还在国内的情况极少,所以先粗筛一遍,剩下的靠人工抽样。
按层级统计空值率的目的是判断这份数据到底能用到哪一层。经验是省级几乎 100% 有坐标,市级 95% 以上,区县级可能掉到 80% 上下,街道级基本靠不住。如果业务只需要到区县,空值率超过 5% 就要考虑自己补点,或者用上级中心点兜底。
注意:中心点坐标和行政中心坐标是两回事。有的数据包给的是「几何中心」,落在山里或者湖里很正常;有的给的是「政府驻地」,那才是可以拿来算距离的点。混用会导致「同城五公里」的结果整体偏移几公里。
3.4 坐标系一致性抽查
坐标能查出范围,查不出偏移。国内常见的三套坐标:WGS-84 是 GPS 原始值,GCJ-02 在其基础上做了非线性偏移,BD-09 又在 GCJ-02 上再叠一层。区划数据包如果不标注坐标系,就必须自己验。
做法很简单:挑三个不同纬度的点(比如东北、华中、华南各一个),把表里的坐标丢进任意地图服务做逆地理编码,看解析回来的名称对不对得上。GCJ-02 与 WGS-84 之间的偏移量在 50 米到 500 米之间浮动,区县中心点这个尺度上,偏移 300 米通常还落在同一个区里,所以名称能对上;但如果拿去做门店级精度判断,这几百米就是致命的。
一旦确认是 WGS-84 而业务侧用的是 GCJ-02,别在查询时实时转换,性能扛不住。正确做法是导入阶段就转换完再入库,表里只留一套坐标,并在字段注释里写死坐标系名称。
4. 查询落地:三级联动、祖先链与五公里排序
结构对了、数据干净了,剩下的就是把这几个高频查询写顺手。这一章给的都是能直接抄的 SQL,参数怎么调在注释里。
4.1 省市区三级联动:全量缓存还是按需查
区划表的数据量决定了最优解是「一次查全量,应用层建树」。
-- 启动时全量加载,三千多行,一次查询几十毫秒 SELECT code, parent_code, level, name, short_name, is_leaf FROM sys_region WHERE status = 1 ORDER BY level, code;拿回来在内存里按parent_code建Map<String, List<Node>>,前端每次切换只查内存。三千行的对象图大概占几 MB,对任何服务都不算负担。反过来说,每次下拉都打一次库才是不合理的——三级联动一次完整的用户操作会触发两次查询,QPS 放大两倍,而数据其实一年都不变几次。
按需查的写法也有用武之地,比如后台管理里只查某一层:
-- 查某省下的所有市(用编码前缀,走 uk_code 索引) SELECT code, name FROM sys_region WHERE code LIKE '9901%' AND level = 2;LIKE '9901%'是前缀匹配,能走索引,不是全表扫描。这一点经常被误解,认为LIKE一定慢——只有%xxx这种前置通配才走不了索引。
4.2 递归 CTE 拿祖先链和子树
MySQL 8.0 支持WITH RECURSIVE,查祖先链终于不用在应用层循环了。
-- 给定一个区县编码,向上取出 省 > 市 > 区 的完整路径 WITH RECURSIVE chain AS ( SELECT code, parent_code, level, name, 0 AS depth FROM sys_region WHERE code = '990101' UNION ALL SELECT p.code, p.parent_code, p.level, p.name, c.depth + 1 FROM sys_region p JOIN chain c ON p.code = c.parent_code WHERE c.depth < 5 -- 加深度上限,防御脏数据造成的环 ) SELECT * FROM chain ORDER BY depth DESC;拆开说:锚点部分是起点那一行,UNION ALL后面的递归部分每次用parent_code找回父级,depth累加。最后的ORDER BY depth DESC让输出变成「省→市→区」的自然顺序。
depth < 5这个上限不能省。一旦数据里有 A 的父级是 B、B 的父级又是 A 的环,没有上限的递归会一直跑到cte_max_recursion_depth(默认 1000)才报错,中间白烧 CPU。加上限是最便宜的后悔药。
反方向查子树同理,把JOIN条件换成p.parent_code = c.code即可。但子树查询我更推荐直接用编码前缀,简单且走索引:
-- 某市下所有区县,比递归快且好读 SELECT code, name FROM sys_region WHERE code LIKE '9901%' AND level = 3 AND status = 1;4.3 附近 N 公里:球面距离加边界框裁剪
「找出某点周围五公里的区县」这类需求,直接对全表算球面距离是能跑,但纯属浪费。标准做法是先粗筛后精算。
SET @lng = 116.407526, @lat = 39.904030, @r = 5; -- 中心点与半径(公里) SELECT code, name, ROUND(6371 * 2 * ASIN(SQRT( POWER(SIN((@lat - lat) * PI() / 360), 2) + COS(@lat * PI() / 180) * COS(lat * PI() / 180) * POWER(SIN((@lng - lng) * PI() / 360), 2) )), 3) AS distance_km FROM sys_region WHERE lng BETWEEN @lng - @r / (111 * COS(@lat * PI() / 180)) AND @lng + @r / (111 * COS(@lat * PI() / 180)) AND lat BETWEEN @lat - @r / 111 AND @lat + @r / 111 HAVING distance_km <= @r ORDER BY distance_km LIMIT 20;WHERE里的两个BETWEEN就是边界框:纬度一度约 111 公里,经度一度要乘以cos(纬度),所以五公里对应的经度跨度在高纬度地区会更宽。这一步把候选集从几千行砍到几十行,lng、lat上如果有联合索引还能进一步加速。
HAVING里的 Haversine 公式负责精算,6371是地球平均半径(公里),PI()/360是把「度差的一半」换算成弧度的一半,整个式子算出来就是大圆距离。用HAVING而不是WHERE是因为distance_km是别名,WHERE阶段还不存在。
注意:这个公式算的是球面直线距离,不考虑路网。要算驾车距离得接路径规划服务,别指望一张区划表能给出答案。
4.4 地址文本反查区划
脏地址清洗是最常见的落地场景:一堆「某省某市某区某某路 88 号」的自由文本,要拆出编码。
思路是从粗到细逐级匹配,用full_name前缀命中。
-- 第一步:用省级全称做前缀匹配,缩小范围 SELECT code, name FROM sys_region WHERE level = 1 AND '某省某市某区某路88号' LIKE CONCAT(name, '%') LIMIT 1; -- 第二步:拿到省编码后,在其下面找市,依次下钻 SELECT code, name FROM sys_region WHERE level = 2 AND code LIKE '99%' AND '某省某市某区某路88号' LIKE CONCAT(name, '%') LIMIT 1; -- 第三步:兜底,用简称列匹配("某省"去掉"省"字后的形态) SELECT code, name FROM sys_region WHERE level = 1 AND '某省某市某区某路88号' LIKE CONCAT(short_name, '%') LIMIT 1;这套写法覆盖了大部分情况,但有两个短板:一是地址里出现「某某自治区」「某某自治州」这种长名称时,简称匹配容易断;二是地址跳级(只写了省和区,没写市)时会卡在第二步。工程上的处理方式是加一层模糊兜底——第二步失败就跳过市级,直接在省级子节点里找level=3且名称被文本包含的行,宁可多匹配几次也不留空。
5. 避坑记录:五个高频翻车现场与排查顺序
数据能导入、SQL 能跑通,不代表上线没事。这一章的五条都是实际会遇到的问题,按「现象 → 原因 → 解决」写,遇到时可以对号入座。
5.1 坐标整体偏移几百米,点位落在隔壁区
现象:地图上区县中心点看着没问题,但用门店坐标做「归属哪个区」的判断时,边界附近的门店大量错判。
原因:数据包是 WGS-84,业务侧的坐标来自地图服务,是 GCJ-02,两套坐标系之间是非线性偏移,不是简单的平移,无法用一个固定差值修正。
解决:统一坐标系。要么在导入时把数据包转换成 GCJ-02 再入库,要么在业务侧存双份坐标(原始 WGS-84 加展示用 GCJ-02)。绝对不要在查询时实时转换,转换函数带大量三角函数和查表,放WHERE里等于全表计算。转换完在字段注释里写死坐标系,后接手的人才不会猜。
5.2 区县列表少了几条,前端下拉选不到
现象:某个市下面的区县比实际少两三个,用户反馈「找不到我们区」。
原因:一是数据包版本旧,撤县设区之后新编码没跟上;二是有区县编码在导入时被当成数字处理,前导零丢了(比如000000变成0),CHAR(6)列被截断或补空格后与业务表关联失败。
解决:先跑 3.2 节的孤儿检测,确认是缺行还是关联失败。如果是前导零问题,检查数据源是不是被 Excel 打开过——Excel 会把012345这种编码当成数字12345。用文本方式读原始文件,或者导入时按字符串读取。版本问题则在版本表里解决,见下一章。
5.3 导入报 3948 或中文全是问号
现象:LOAD DATA报ERROR 3948 (42000): Loading local data is disabled,或者导完之后名称列全是???。
原因:前者是 MySQL 8.0 默认关闭了local_infile,客户端和服务端都要开;后者是连接字符集与文件编码不一致,CSV 是 UTF-8 而连接是latin1或gbk。
解决:服务端SET GLOBAL local_infile = 1;,客户端连接串加--local-infile=1,JDBC 上加allowLoadLocalInfile=true。字符集问题在LOAD DATA语句里显式写CHARACTER SET utf8mb4,并在连接串里指定characterEncoding=utf8。已经导坏的先TRUNCATE再重来,别试图UPDATE修补,成本更高。
5.4 递归查询把数据库 CPU 拉满
现象:某个接口偶发超时,慢查询日志里全是带WITH RECURSIVE的语句。
原因:数据里存在父子成环的脏行,递归一路跑到上限才停;或者接口被高频调用,每次都在算同一棵树。
解决:递归里强制加深度上限(前面用到的depth < 5),这是第一道保险。同时给parent_code建索引,否则每一层递归都是全表扫描。业务层对结果做缓存,区划树一年变不了几次,缓存一天的命中率接近 100%。根治办法是导入时做一次环检测,把成环的节点挑出来单独处理。
5.5 5.7 上跑不了递归 CTE
现象:本地用 8.0 开发一切正常,发到测试环境 5.7 报语法错误。
原因:WITH RECURSIVE是 8.0 才有的特性,5.7 不支持;同理utf8mb4_0900_ai_ci、窗口函数也都不可用。
解决:要么升版本,要么用编码前缀替代递归——查子树用LIKE '9901%',查祖先链用SUBSTRING(code,1,2)、SUBSTRING(code,1,4)分段拼接,配合三次自连接也能出结果:
-- 5.7 上取祖先链的替代写法,三次 JOIN 换三次字符串截取 SELECT p1.name AS province, p2.name AS city, p3.name AS district FROM sys_region p3 LEFT JOIN sys_region p2 ON p2.code = CONCAT(LEFT(p3.code, 4), '00') LEFT JOIN sys_region p1 ON p1.code = CONCAT(LEFT(p3.code, 2), '0000') WHERE p3.code = '990101';写法丑,但因为编码本身含层级信息,逻辑是对的,而且能走索引。这也是我在第 2 章坚持保留六位编码、不用纯自增 ID 做关联的原因——它替你在低版本上留了一条后路。
6. 版本化与纠偏:把区划表做成能长期维护的底座
区划数据最容易忽略的一点是它会变。撤县设区、改名、编码调整每年都有,而业务表里的历史订单还存着旧编码。直接UPDATE覆盖,历史订单的关联就会断掉;不更新,新用户又选不到新区。这个矛盾在项目上线一年后一定会爆发。
我的处理方式是加一张快照维表,用生效区间做时间旅行:
| 字段 | 类型 | 说明 |
|---|---|---|
| code | CHAR(6) | 区划编码 |
| name | VARCHAR(64) | 当期名称 |
| parent_code | CHAR(6) | 当期父级 |
| valid_from | DATE | 生效日期 |
| valid_to | DATE | 失效日期,当前有效填9999-12-31 |
| change_type | TINYINT | 1 新增 2 更名 3 撤并 4 编码调整 |
变更时不做UPDATE,而是把旧行valid_to改成变更日、再插一条新行。业务查「当前区划」加WHERE valid_to = '9999-12-31',查「某历史订单下单时的区划」用订单日期落在valid_from和valid_to之间。主表sys_region仍然只存当前态,保证下拉查询不受影响,两张表用code关联。
另一个常被低估的动作是坐标纠偏的一次性脚本。如果确认数据包是 WGS-84 而业务要 GCJ-02,别写在线转换,写个离线脚本跑一遍,把结果直接落库,脚本本身打上版本号和数据源哈希存档。原因很现实:坐标转换涉及查表插值,不同实现之间结果会有细微差异,线上实时转换意味着同一个点在不同时间可能算出不同坐标,排查问题时根本对不上账。
最后分享一个从踩坑里长出来的习惯。早期我拿到任何区划数据包,都是直接source进库然后开写业务,直到有次上线前一天发现某直辖市的区县整层 parent 挂错,三十多个区的下拉全部空白,连夜补数据。从那以后我每次导入完都强制走一遍第 3 章的三道校验,把三条 SQL 存成一个check.sql,跑完看一眼行数是不是 0 才继续。这个动作花不到两分钟,但省下来的返工时间是以天计的。希望帮到你。
本文还有配套的精品资源,点击获取