招聘平台的城市类目系统最早是简单的两级结构(省→市),后来业务发展需要三级(省→市→区)、商圈、热门城市等多种维度,旧系统越来越难维护。2024年11月做了城市类目系统的重构,今天把完整改造过程分享出来。
一、旧系统问题#
- 层级混乱:有的地方两级,有的地方三级,数据不一致
- 字段冗余:城市名、拼音、缩写、经纬度等字段散落在多张表
- 维护困难:加个商圈要改多张表,容易漏
- 接口不统一:不同业务线调用城市类目的接口不一样,返回格式不同
- 没有版本管理:城市类目变更没有记录,出问题查不到历史
二、新数据模型#
用统一的树形结构,支持任意层级:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| CREATE TABLE `region` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`parent_id` int(11) NOT NULL DEFAULT '0' COMMENT '父级ID,0为顶级',
`level` tinyint(1) NOT NULL COMMENT '层级:1省 2市 3区 4商圈',
`name` varchar(50) NOT NULL COMMENT '名称',
`pinyin` varchar(100) DEFAULT NULL COMMENT '拼音',
`pinyin_short` varchar(20) DEFAULT NULL COMMENT '拼音缩写',
`longitude` decimal(10,6) DEFAULT NULL COMMENT '经度',
`latitude` decimal(10,6) DEFAULT NULL COMMENT '纬度',
`sort` int(11) NOT NULL DEFAULT '0' COMMENT '排序',
`is_hot` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否热门',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用',
`created_at` int(11) NOT NULL,
`updated_at` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `parent_id` (`parent_id`),
KEY `level_status` (`level`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
用 parent_id + level 表示层级关系,支持任意深度扩展。
三、接口设计#
统一的 RESTful 接口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // 获取子级类目
GET /api/v1/regions?parent_id=1&level=2
// 获取完整树形结构
GET /api/v1/regions/tree
// 获取热门城市
GET /api/v1/regions/hot
// 搜索城市
GET /api/v1/regions/search?keyword=杭州
// 根据经纬度反查城市
GET /api/v1/regions/reverse?lng=120.15&lat=30.28
|
统一返回格式:
1
2
3
4
5
6
7
8
9
10
| {
"code": 0,
"data": [
{"id": 1, "name": "浙江省", "level": 1, "children": [
{"id": 2, "name": "杭州市", "level": 2, "children": [
{"id": 3, "name": "西湖区", "level": 3}
]}
]}
]
}
|
四、接口校验#
用 Go 的 validator 做参数校验:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| type RegionQuery struct {
ParentID int `form:"parent_id" binding:"omitempty,min=0"`
Level int `form:"level" binding:"omitempty,oneof=1 2 3 4"`
Keyword string `form:"keyword" binding:"omitempty,max=50"`
}
func (h *RegionHandler) List(c *gin.Context) {
var query RegionQuery
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(400, gin.H{"code": 1, "message": err.Error()})
return
}
// ...
}
|
五、数据迁移#
旧系统数据迁移到新表:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| func migrateRegions() error {
// 1. 迁移省份
provinces := queryOldProvinces()
for _, p := range provinces {
newID := createRegion(p.Name, 0, 1, p.Sort)
// 记录映射关系
setMapping("province", p.ID, newID)
// 2. 迁移城市
cities := queryOldCities(p.ID)
for _, city := range cities {
cityID := createRegion(city.Name, newID, 2, city.Sort)
setMapping("city", city.ID, cityID)
// 3. 迁移区县
districts := queryOldDistricts(city.ID)
for _, d := range districts {
createRegion(d.Name, cityID, 3, d.Sort)
}
}
}
return nil
}
|
迁移完成后,业务表中的 city_id 要更新为新的 ID(用映射表)。
六、缓存策略#
城市类目变更频率低,查询频率高,用 Redis 缓存:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| func (s *RegionService) GetTree() ([]*Region, error) {
// 查缓存
cached := s.redis.Get("region:tree").Val()
if cached != "" {
var tree []*Region
json.Unmarshal([]byte(cached), &tree)
return tree, nil
}
// 查数据库
tree := s.repo.GetTree()
data, _ := json.Marshal(tree)
s.redis.Set("region:tree", data, 24*time.Hour)
return tree, nil
}
// 类目变更时清缓存
func (s *RegionService) Update(id int, data *Region) error {
err := s.repo.Update(id, data)
if err == nil {
s.redis.Del("region:tree") // 清缓存
}
return err
}
|
七、踩坑经验#
- 层级深度限制:理论上支持任意层级,但实际查询时层级太深性能差。限制最多4级(省→市→区→商圈),够用且性能好
- 城市重名:不同省份有同名城市(如"朝阳区"北京和长春都有),查询时必须带 parent_id 或 level,不能只按 name 查
- 经纬度精度:旧系统经纬度精度不一致(有的6位小数,有的2位),迁移时统一格式化
- 业务表关联更新:城市类目 ID 变了,所有关联 city_id 的业务表都要更新。用映射表批量更新,遗漏会导致数据关联错误
八、总结#
城市类目系统重构核心:
- 统一数据模型:树形结构支持任意层级,字段完整,消除冗余
- 统一接口:RESTful 风格,统一返回格式,所有业务线共用一套接口
- 参数校验:入参严格校验,防止非法数据
- 数据迁移:旧数据清洗后迁移,建立 ID 映射,业务表关联更新
- 缓存优化:变更低频查询高频,用 Redis 缓存,变更时主动清缓存
- 版本管理:类目变更记录日志,可追溯可回滚
基础数据系统的重构关键是"统一"——统一数据模型、统一接口、统一维护入口。旧系统的问题往往是因为不同业务线各自为政,数据模型和接口都不统一。重构时要把这些都收敛到一起,后续维护成本才能降下来。