前段时间接了个项目代号只有三个字母REA。拿到手的时候我有点懵——没有配套文档没有明确的需求列表设计稿也只是一张潦草的导航图。前后开了几轮碰头会才勉强拼出一个相对完整的故事用户希望有一个能统一查房源、看价格、对比区域趋势的 Web 平台。于是 REA 在我们内部被定位成 “Real Estate Analytics”——房地产数据分析平台。主线想清楚之后原本以为是个“做个搜索页面”的小活结果硬生生串起了数据采集、清洗、GIS 聚合、缓存、前端交互一整条链路。这篇复盘就把这条链路完整拆给你看从一个空荡的三字母代号出发怎么收敛需求边界怎么选型搜索/地图/趋势三大核心怎么落地上线前又踩了哪些坑。适合准备做数据类 Web 产品、地图类应用或者正对着模糊需求无从下手的开发者参考。1. 三字母代号背后的真实边界REA 到底要解决谁的什么问题1.1 先做需求澄清不是猜谜很多项目拿到手最怕的不是文档多而是文档为零。REA 这个代号能指代的东西太多了是“Retrieve Everything App”还是“Rental Estate Agent”如果上来就写代码大概率会在第二周推倒重来。所以我做的第一件事不是画架构图而是拉着相关方一起过了一遍最基础的问题清单。我把问题分成四类给谁用面向找房用户还是面向房产运营人员还是两者都要解决什么是查信息还是做交易还是只看趋势数据在哪数据源已经存在还是需要从零采集成功标准上线后看什么指标PV搜索转化率还是用户停留时长答案其实挺有意思。找房用户觉得“搜得准、地图顺、价格真实”最重要运营人员则希望有个看板能快速看到各商圈挂牌量、均价变化和异常房源。这两拨人看似需求不同但在数据层面完全可以共用一套底座先搭建一个统一的房源数据仓库再在这一层之上分别提供 C 端检索页面和 B 端分析看板。1.2 需求落地不做重后台做“检索 趋势”闭环澄清完之后需求范围比预期收窄了很多。我们明确不做交易流程不做在线签约也不做复杂权限系统。REA 只有三件事要做聚合把不同渠道的房源信息统一格式、统一字段、统一更新频率检索支持关键词搜小区/地段/户型按价格、面积、标签过滤配合地图浏览分析以商圈为粒度展示挂牌均价、环比涨跌、在售套数方便做趋势判断。MVP 就这样定下来了。有人会觉得少了点什么比如“智能推荐”要不要做我的意见是不做。原因很简单智能推荐依赖用户行为数据而一个新上线的平台没有积累勉强做出来也只是摆设。不如先把检索和趋势打磨好让用户愿意留下来再去谈推荐逻辑。提示项目代号越模糊越要先问四类问题——“给谁用、解决什么、数据哪来、成败看什么”。这四个答案齐了需求边界自然就浮出来了。2. 技术选型与工程骨架按数据流切模块不按页面堆功能2.1 前端与服务端怎么拆才是这个项目的核心决策技术选型方面我们一开始就定了原则单体起步不搞微服务按数据流切模块而不是按页面切目录。前后端分离是必然的前端选了 React 18 TypeScript Vite后端选了 Python FastAPI。为什么这样组合先看前端。React 生态里做地图交互、状态管理、列表虚拟滚动都有成熟方案TypeScript 对房源字段这种强结构化数据非常友好。Vite 作为构建工具热更新速度比旧工具链快一个量级跑起来真心省时间。再看后端。使用 FastAPI 主要是看中它的三样东西第一异步能力对并发查询友好第二自动生成接口文档第三Python 生态在地理信息处理和数据处理上太方便了。房源数据清洗必然要写一堆半临时脚本用 Python 写起来比 Node.js 顺手不少。存储这一层我纠结过一阵子。最终选了 PostgreSQL 加 PostGIS 扩展。原因不复杂房源数据本身是关系型结构PostgreSQL 的 JSONB 字段又能兼顾后期字段扩展PostGIS 提供的地理函数可以处理“查某个商圈内的房源”“按经纬度聚合”这类高频需求。当前阶段不需要上单独的搜索引擎PostgreSQL 的全文检索加 gin 索引对几十万条数据的查询压力完全够用。缓存选了 Redis但使用范围控制得很小心。只缓存两类东西热门搜索词的结果聚合以及地图瓦片统计结果。房源明细数据不做整条缓存避免出现用户搜到一套房子点开发现已经下架的尴尬情况。2.2 数据模型和目录结构把“分析”刻进骨子里REA 既然叫 Analytics数据模型的设计就得把分析维度前置。一张房源主表是跑不掉的核心字段包括房源编号、渠道来源、城市、区域、商圈、小区名、户型、建筑面积、挂牌价、租金类型、经度、纬度、状态、抓取时间。比较重要的是我们没有用单一“城市小区”作为唯一键而是加了一个归一化字段。原因是什么呢因为多个渠道的房源小区名可能写法不一样“xxx花城”和“xxx花园”实际上是同一个小区。为了后续准确聚合我们在清洗时单独维护了一张小区维表把别名映射到标准小区 ID房源主表存的是标准小区 ID而不是原始字符串。这个设计在后来做商圈趋势分析时帮了大忙。目录结构我们分成五块rea-server/ app/ api/ # 接口层检索、地图、趋势、收藏 services/ # 业务层业务逻辑、缓存策略 repositories/ # 数据层SQL 查询、ORM 映射 models/ # SQLAlchemy 模型 collector/ # 定时采集与清洗脚本 analysis/ # 离线统计任务 tests/ # 单元与集成测试前端目录也对应分成三层页面层只负责组合组件组件层负责展示和交互逻辑使用的 store 层只放跨页面共享的全局状态比如当前城市、筛选条件、收藏列表。地图相关组件单独放一个模块不和其他业务组件混在一起因为地图的依赖较重单独拆分有利于后续性能优化。3. 核心功能实现搜索、地图聚合与价格趋势一个都不能少3.1 多条件检索全文索引和地区过滤的配合搜索是 REA 的门面。用户在这里输入“科技园三房”系统需要在几百毫秒内返回相关房源。我们用的是 PostgreSQL 全文检索加组合过滤。做法是建一个处理过的搜索字段把小区名、区域、商圈、推荐标签拼接成一个 tsvector再建 gin 索引。查询的时候把用户输入的分词结果转成 tsquery用中文分词后拼条件。SQL 大致长这样SELECT id, title, district, area, price FROM housing WHERE tsvector_col plainto_tsquery(chinese, :kw) AND city_id :city_id AND status 1 AND price BETWEEN :min_price AND :max_price ORDER BY ts_rank(tsvector_col, plainto_tsquery(chinese, :kw)) DESC LIMIT 20 OFFSET :offset;这样一套组合下来在约四十万行数据上测试去掉首次冷查询基本稳定在 120 毫秒到 250 毫秒之间。3.2 地图聚合从散点绘制到按网格聚合地图部分是 REA 里最容易被低估的模块。一开始我们用最简单的方式把当前视野内所有房源以散点形式丢到地图上。结果在房源密集的中心城区一次性绘制两三千个标记点滚动时明显掉帧。此时我意识到地图不能这么画必须做聚合。PostGIS 正好提供了现成的聚合思路把地图按不同的缩放级别切成网格把房源按网格聚合后再返回。前端在缩放结束时请求一次当前视野范围的聚合数据后端 SQL 大致如下SELECT ST_AsGeoJSON(ST_Centroid(ST_Collect(geom))) AS center, count(*) AS cnt FROM housing WHERE geom ST_MakeEnvelope(:lng1, :lat1, :lng2, :lat2, 4326) AND status 1 GROUP BY ST_SnapToGrid(geom, :grid_size) ORDER BY cnt DESC;这里的 grid_size 根据地图缩放级别动态调整级别越大网格越小级别越小网格越大。前端拿到聚合结果后用 marker cluster 渲染点击聚合点再下钻到更细的级别。改造之后地图从一次画几千个点变成一次画几十个聚合点流畅度提升非常明显。3.3 价格趋势不只看均价还要看分位数后来为了好看又实用的看板我们把“均价”作为最基础的指标但懂数据分析的都明白平均值容易被极端值拉偏。一栋豪宅挂 3000 万旁边十套刚需盘挂 300 万平均价直接翻了一倍这个数字根本不具备参考价值。所以最终实现时我们把商圈趋势指标拆成分位数口径P25、P50、P75 和平均价一起展示。用户看到中位数是 280 万心里马上就有锚点了。环比涨跌也统一按中位数计算避免单套极端房源干扰判断。离线统计脚本每天跑一次按商圈计算中午挂牌量、中位数价格、各面积段的套数分布写入统计表。前端再接一个简单折线图一个非常朴实但真正有用的趋势模块就出来了。4. 数据清洗与供给层数据质量比功能上线更早决定项目成败4.1 数据源准入和脱敏不能拍脑袋随便接REA 的根基是数据。数据脏、数据缺、数据过期前端做得再漂亮也没人用。我们的数据主要来自模拟数据源和一些公开的测试数据集正式环境会接入合规授权的渠道。无论来源是什么进入核心库之前都要过三道关卡字段完整性检查经纬度、价格、城市、区域缺一不可缺了就进待补池。格式统一价格统一转为以“万元”为单位面积统一转为“平方米”楼层统一为数字。隐私与脱敏处理联系方式、具体门牌号等敏感字段不允许进入检索模型只能存在权限受限的源数据区。脱敏这件事我特别想多说一句。像房源这种和真实住址强相关的数据如果因为疏忽把门牌号塞进了搜索索引一旦有问题就是大事故。所以我们压测时专门写了校验脚本从线上索引抽样验证确保敏感字段没有流出。4.2 归一化和去重同一个小区不能有第三套写法数据清洗最耗时的工作是小区名归一化。为了后续的商圈聚合准确我们构建了一张 dict 表标准小区ID标准名称别名集合城市10001某翠苑[某翠苑, 翠苑华庭, 翠园]某市10002某江花园[某江花园, 江畔花园, 滨江花园]某市清洗脚本跑的时候先把所有渠道的房源名称切成 token做字符串相似度匹配召回潜在的同一小区组合再由规则加人工抽检确认别名映射写回配置表。这个过程听起来简单实际做下来才知道有多少边角料某渠道把小区名后缀带上“一期”另一个渠道却直接叫“一期”同一个小区能聚出五六个写法。去重逻辑也定得很严格。判定重复的条件是渠道不同 同一小区 同一户型 面积相差小于 5%。命中重复的房源只保留最新抓取的那条其余标记为 inactive。经过这一轮清洗原本看着像三十万条的数据实际可用的只剩二十五万条左右——这个数字倒让我松了一口气至少库里的水分挤掉了不少。4.3 异常值检测价格跳变不能直接进趋势图房源数据里还有一类问题叫“挂牌价格异常”比如一套 130 平米的房源挂 3.5 万元一平米明显输入错误再比如同一个小区的同一套户型从上个月到本周价格跳涨 60%大概率是录入问题或者挂错了数字。针对这两类情况清洗脚本里加了规则对每个标准小区的在售房源计算单价的 P25 和 P75凡是单价低于 P25 减三倍四分位距或高于 P75 加三倍四分位距的自动进入异常池不参与前端展示。另一条规则是同一小区同一户型的价格环比变动超过 30% 的自动打上“价格波动需复核”标签等人工确认后再更新趋势数据。这一步很值得放在架构里说宁可让极少数真实但极端的房源暂时不可见也不能让一个脏数字毁掉用户对趋势页的信任。5. 上线前真实的踩坑记录地图卡顿、异步竞态和缓存穿透5.1 地图聚合后依然卡顿问题不在渲染而在请求时机地图从散点改成聚合后按理说应该流畅了但测试环境里居然还是卡。打开控制台才发现每次地图缩放松手的一瞬间前端发了十几个请求后端每个请求都去做一次实时 SQL 聚合。用户连续缩放几趟请求队列直接被塞满。根因是聚合结果没有做缓存。地图视野跨过整个城市时网格聚合结果是相对固定的一天内不会有太大变化。解决办法是给聚合接口加 Redis 缓存用“缩放级别 视野边界”作为缓存的 key过期时间设成两小时。同时前端加了防抖拖拽结束 300 毫秒后才发起请求。这样改造后地图拖拽就变得非常顺滑请求量也降到了原来的十分之一。5.2 异步请求竞态用户切得比接口返回还快地图和列表数据分开请求后又出现了一个隐蔽的 bug当用户在搜索页快速切换筛选条件时页面展示的数据有时候对不上当前条件。比如先选“三室”再选“两室”如果“三室”的请求响应慢、“两室”的响应快后展示出来的列表反而先被“两室”覆盖然后又跳回“三室”。这是一个典型的异步竞态问题。修复方式也很标准前端在发起新请求时用 abortToken 取消旧请求另外在响应回来时判断当前请求序号是否还是最新不是最新的直接丢弃。两个手段叠在一起竞态就基本消失了。这类问题在写单个功能时很难被发现只有在真实网络环境、真实用户操作下才会频繁触发。所以我把这条单独拿出来记录是希望大家在联调阶段就留意一下请求竞态而不是上线后再被用户教做人。5.3 地点搜索的精度各级用户的查询习惯完全不一样还有一个我们要克服的问题不同来源对地址的表达习惯差异很大。有人输入“科技园”有人输入“南山区科技园”还有人输入“科苑路与科技南十路交叉口”。如果不做文本归一化很多有效查询根本匹配不到数据。最后我们的处理方式是在检索层面叠加了三级策略第一级尝试精确匹配商圈/区域第二级做前缀模糊匹配第三级做分词倒排匹配利用 PostgreSQL 的 pg_trgm 扩展加大写相似度召回。但搜索结果保留了优先级排序精确匹配永远是第一梯队模糊匹配放后面确保命中率的同时不让用户觉得结果太发散。5.4 排查手段把每次查询链路都记录下来碰到难排查的性能问题我的经验是必须在应用层打链路日志。前端请求带一个 request_id后端从网关开始逐层透传中间件记录每一步耗时。解析请求参数耗时多少、SQL 查询耗时多少、序列化耗时多少、Redis 命中还是未命中一目了然。REA 上线前后我们靠这套日志定位了三个隐患一个是某个商圈房源数据异常多导致该网格聚合结果超大一个是某条 SQL 在无缓存冷启动时扫描行数巨大还有一个是因为某些区域数据是空的前端拿到空数组后没有做兜底直接白屏。这类问题如果只靠肉眼看页面往往要折腾很久但有了链路日志基本五分钟就能定位到具体模块。6. 部署、监控与后续演进让 REA 真正可长期维护6.1 容器化部署和流量入口配置部署上选了相对稳妥的方案前端用 Nginx 托管静态文件并开启 gzip后端用 Docker 容器跑 FastAPI 服务。数据库单独一台机器不和应用容器混部避免数据库资源被应用突发流量挤爆。整套部署用 Docker Compose 编排包含四个服务Web 静态服务、API 服务、Redis、定时器容器。Nginx 做了两层缓存一层是针对静态资源的强缓存一层是针对地图聚合接口的微缓存。对于地图这种大规模坐标数据接口缓存能省下不少后端压力。关于健康检查也要刻意提醒一句API 服务的健康检查接口不要用默认根路径应该单独定义一个/healthz,里面检查数据库连接和 Redis 连接是否正常。否则可能出现服务进程活着但数据库连接池已满的情况而负载均衡仍然以为服务健康继续往里面转发流量。6.2 上线后要盯的指标和接下来的方向上线后我们只盯三个核心指标检索接口 P95 耗时、地图聚合接口成功率、趋势页每日访问量。这三个指标直接对应 REA 的三个核心功能任何一个出问题都能第一时间感知。后续的演进方向我梳理了两个。第一是给城市和商圈建一个更细粒度的 “热度指数”基于搜索次数和详情页浏览次数计算让用户在找房时能直观看到哪些区域被关注得更多。第二是在数据积累足够后跑一个简单的价格预测模型用时间序列预测商圈内未来几个月的价格区间作为参考辅助而不是一个精确的房价预言。最后给同样面对模糊项目的朋友一个实战总结多花时间在需求澄清和数据模型上远比多写几个页面有价值。REA 的三字母代号现在回头看更像是一个提醒——功能再多都不如把数据这条链路做得扎实、高效、可信。前端再好一旦数据是脏的用户也只会觉得这平台不靠谱。后续如果再让我重做一遍 REA我会在第一天就先把小区维表和价格口径定下来把数据清洗脚本写好再谈页面和交互。数据层稳了上面长什么功能都是水到渠成的事。