选举2029:邮政编码
在上一篇关于记录和集合的实践性文章之后,本篇内容可能不会给读者提供太多可直接应用于自身项目的思路,也没有向微软提出任何功能请求。但这个领域确实让我感到非常有趣。
英国邮政编码简介
我意识到本文大部分读者可能不在英国。多数国家都有某种形式的邮政编码,但具体细节因国家而异。例如,英国邮政编码的粒度与美国邮政编码大不相同。英国邮政编码非常精细——通常仅对应一条街道的一部分——因此只要知道门牌号和邮政编码通常就能精确定位地址。
对我的选举网站重要的是,每个邮政编码都唯一对应一个选区——这也正是我想利用的特性。我的选区页面允许用户输入选区名称或邮政编码的前缀,并实时过滤结果显示。我推测相当一部分英国居民知道自己的邮政编码但不清楚所属选区名称(特别是在2023年选区边界调整后),因此支持两种查询方式很有帮助。
维基百科有关于英国邮政编码的更多信息,这里简要总结。注意我只关心现代邮政编码——维基百科有历史演变细节,但我的网站无需处理过时的邮政编码。
邮政编码由外部码(outcode)和内部码(incode)组成。外部码包含区域和地区,内部码包含扇区和单元。以我的邮政编码RG30 4TT为例:
- 外部码:RG30
- 区域:RG
- 地区:30
- 内部码:4TT
- 扇区:4
- 单元:TT
内部码格式统一:总是1位数字后跟2个字母(字母来自20个字符的字母表)。外部码则更"有趣",分为以下七种格式(9代表数字,A代表字母):
- A9
- A99
- A9A
- AA9
- AA99
- AA9A
注意前两种格式仅区别在于数字位数——且内部码总是以数字开头。在解析完整邮政编码时这不是问题(可通过假设最后三个字符是内部码来判断外部码长度),但在解析不完整邮编时会产生歧义。例如"RG31 4FG"和"RG3 1AA"都是有效邮编,当用户输入"RG31"时(不强制输入空格),应同时显示两个选区(“Reading West and Mid Berkshire"和"Reading Central”)。
需求分析
选举网站对邮政编码的使用需求很明确:
- 从外部源(国家统计局)获取数据
- 以紧凑格式存储所需数据,理想情况下存入单个Firestore文档(1MB限制)
- 在内存中保持合理紧凑的格式
- 提供API:输入"邮政编码前缀",返回"匹配该前缀的所有选区集合",要求响应速度达到"瞬时"感受
有趣的是,维基百科提到"170万条邮政编码",但我当前处理的文件有2,712,507条记录。过滤后仍有2,687,933个独立邮编。不确定维基百科的数字是笔误还是确实存在差异。
剧透提示:网络延迟远超过API请求的处理延迟。在慕尼黑机场WiFi下实测往返延迟约60毫秒,页面更新感觉几乎是即时的。而ASP.NET Core内部处理时间不足1毫秒(包含按选区名称的过滤)。
存储与内存格式
逻辑上,输入数据只是"邮政编码,选区代码"的序列。选区代码长9字符。如果简单存储271万条记录,每条占用16字符(邮编平均7字符+选区代码9字符),将接近42MB,这还不包括数据分割的开销。我们肯定能做得更好。
数据存在大量冗余。首先,简单计算假设每个字符占1字节,但实际只需36字符(A-Z,0-9)的字母表——且部分位置只能是数字。
按外部码分组
在考虑复杂编码前,实际数据有更多冗余。邮政编码并非随机分配,相同扇区(如"RG30 4")的邮编通常属于同一选区。即使按外部码(如"RG30")分组,每个组包含的选区数也有限。
当前有3077个外部码:
- 851个外部码对应1个选区
- 975个对应2个选区
- 774个对应3个
- 348个对应4个
- 98个对应5个
- 21个对应6个
- 8个对应7个
- 2个对应8个
每个外部码对应4000个可能的内部码(0AA到9ZZ)。因此每个外部码只需一个4000值的数组。
简单方案每值占1字节:4000字节/映射 × 3077映射 = 12,308,000字节。
压缩映射
能否利用数值范围优化?对于851个单选区外部码,每值只需1位;975个双选区外部码每值需2位,依此类推。计算总空间:
|
|
这比原始映射节省了3/4空间!但仍超过Firestore文档限制,且还需存储外部码文本和选区列表。
尝试过按扇区分组、手动游程编码等多种方案,代码越来越复杂。本质上我是在手动实现数据压缩。
后来决定转向更简单的方法:使用通用压缩算法DeflateStream/InflateStream。结果超出预期:压缩后映射总大小仅500,602字节,为额外信息留出了充足空间。
我曾考虑使用Google Cloud Storage替代Firestore以规避压缩,但当前压缩代码仅约15行,复杂度隔离良好。
请求处理
存储需求满足后,工作基本完成。内存中保持解压状态,每个外部码映射使用1字节/内部码,占用约12MB内存。每个映射存储为OutcodeMapping对象。
前缀转换到选区集合的过程因格式歧义而复杂。单字符输入不返回结果——要求用户至少输入两个字符。
建立"前两个字符→可能的OutcodeMapping列表"的字典很容易。该列表总是很短,可快速筛选出匹配前缀的条目。对每个匹配的OutcodeMapping,需根据剩余前缀的内部码部分查找可能选区。
例如用户输入"RG30"时,快速筛选出匹配外部码"RG3"和"RG30"。然后需要确定:
- RG3外部码中以"0"开头的内部码对应哪些选区?
- RG30外部码的完整内部码对应哪些选区?(此前缀已消耗完该外部码所有字符)
通过先分离外部码再处理内部码,利用内部码格式简单的特性简化逻辑。处理时根据剩余字符数分情况:
- 零字符:返回该外部码所有选区
- 一个字符:返回该扇区的选区集合(可轻松缓存,每个外部码只有10个扇区)
- 两个字符:遍历该"半单元"的20种可能性
- 三个字符:精确查找单个邮编,结果为空(无效邮编)或单条目结果
当前实现可能还有优化空间,但现有速度已足够快。未来可能进行负载测试,但预计不会成为问题(必要时Cloud Run会自动扩容)。
结论
这个主题有两点让我特别欣赏。首先,它的独立性。无需了解网站其他部分、选举民意调查或政党信息就能理解这个领域,只需理解邮政编码和选区概念。这使得它成为愉快的写作主题。其次,以相对较小的努力获得惊人效果:约0.5MB数据存储了270多万条邮编信息,且查询成本极低。
文中方法存在有趣的近似矛盾:一方面,数据虽然映射6/7字符输入到9字符输出,但远非随机。只有650个选区,且邮编具有明显聚类特征(相差第五位以后字符的邮编很可能属于同一选区)。我采用的方法首先利用领域知识进行初步处理,这部分是高度领域特定的。仅压缩"邮编-选区代码"文件只能将大小降至7MB多。
另一方面,当处理"每个外部码对应4000个0-8整数值"时(意识到许多外部码的值范围只是0-1或0-2),“聪明"的手动优化效果不佳。代码随着字节级压缩尝试变得越来越复杂,而应用领域无关的通用压缩算法后效果出色。
在利用已知数据特性编写专用代码与依赖通用算法之间寻找平衡的需求并不罕见,这是我所见过最清晰的案例之一。