news 2026/10/20 16:20:59

信息平台后端实战:REA房源系统的建模、检索与并发设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
信息平台后端实战:REA房源系统的建模、检索与并发设计

REA 这个项目代号,全称是 Real Estate Application,一个面向 C 端的本地房源信息浏览与预约平台。作为这个项目的主力后端,从需求拆解、数据库设计到检索方案落地,整个过程踩过的坑、改过的设计、验证过的方法,我觉得都值得认真记录下来。我会把这套系统拆开讲:数据怎么建模、检索用什么方案、为什么这样选、上线前后遇到了哪些问题,最后又是怎么收尾的。不管你是正在做一个类似的信息展示类平台,还是单纯想看看一个真实项目从零到上线要经历什么,这篇记录应该都能给你一点参考。我会尽量把每个决策背后的理由说透,把代码之外的取舍也讲清楚。

1. 项目概述:REA 在解决什么问题

1.1 需求的原点:谁在找房,谁在发布房源

做这个项目的团队当初其实只有六个人,两个后端、一个前端、一个测试、一个产品、一个设计,我是后端之一。产品那边拿到的需求一句话就能说清:做一个本地的房源信息平台,用户可以在上面按区域、户型、价格筛选房源,看到合适的就发起预约,然后线下带看;同时给运营或经纪人一套简单的房源录入和上下架后台。

需求听起来不复杂,但拆开以后全是细节。房源信息不像普通商品只有一个标题和价格,它有面积、朝向、楼层、户型、装修程度、物业类型、产权年限、配套设施等等。而且同样的房子,在不同状态下,展示信息完全不同。比如"在售"和"已成交"之后,价格和描述就不能再对外展示,需要走整套状态机流转。

我一开始也把它当成一个普通的 CRUD 系统来做,实际上这种信息平台的难点完全不在增删改查,而在三件事:房源数据的规范化建模、多条件组合检索的体验、以及前后端状态的一致性。后面会逐个展开。

1.2 核心用户和两条主链路

系统里有三类角色:

  • C 端用户:浏览、筛选、收藏房源,发起带看预约,查看浏览记录。
  • 运营/经纪人:录入房源、上传图片、管理房源上下架、审核用户提交的房源。
  • 平台管理员:配置城市/区域数据、审核房源、处理举报与异常数据。

这三类角色带出两条核心业务链路。第一条是"房源生命周期链":录入 -> 审核 -> 发布 -> 展示 -> 预约/收藏 -> 成交/下架。第二条是"用户找房链":搜索/筛选 -> 浏览详情 -> 收藏/预约 -> 线下带看 -> 成交反馈。

这两条链路看起来简单,但每条链路里都埋了状态同步、权限控制、幂等防抖这些工程问题。比如预约带看这个动作,同一个时间段同一套房源不能同时被多个用户预约,但页面展示又不能把时间字段锁死,这就要靠后端做并发控制和状态锁。

提示:任何信息平台类项目,第一步都应该先把"状态流转图"画清楚,而不是急着建表。房源在什么状态能做什么操作,谁能触发状态变更,这决定了后面大量的接口权限和业务逻辑。

2. 技术选型:为什么是这套组合

2.1 后端、数据库与整体基调

技术选型那会儿,团队里其实没有谁有特别强的主导意见,本着"用社区最成熟、大家最熟悉的方案"这个原则,定了下面的组合:

层选型理由
后端框架Spring Boot + Java团队主力语言,生态完整,招人成本低
数据库MySQL 8.0数据量在百万级以内,MySQL 完全够用,不用为了"先进"而上复杂组件
缓存/会话Redis缓存热点房源、管理登录会话、分布式锁
检索引擎Elasticsearch多条件组合搜索和地理位置搜索的通用方案
对象存储通用 OSS 兼容服务房源图片多且大,不可能直接存服务器磁盘
消息队列RabbitMQ房源数据变更后异步同步到检索端,削峰解耦

这套组合放到今天看也没什么新意,但新意不重要,稳定和熟悉才重要。团队能在两个多月内上线,靠的就不是炫技,而是大家闭着眼都能排错的栈。

这里有一个当时反复讨论过的点:要不要为了"轻量"而放弃独立检索引擎,只用 MySQL 硬扛搜索?我的结论是,如果房源量在几十万以内、筛选条件不超过三四个,MySQL 加上合理索引完全可以用 LIKE 加索引去扛。但如果要做到"区域+价格区间+户型+朝向+标签"这种多条件实时组合,而且后面还要加"周边一公里找房",纯 MySQL 就是在给自己挖坑。最终我们上了独立检索引擎,但也没有把搜索全部交给它,而是保持 MySQL 为唯一数据源,检索集群只做查询投影。这个设计在后面救了我们很多次。

2.2 前端形态:Web 优先,移动端优先适配

最初产品提的需求是 App,但团队只有一名前端,短期要交付 iOS 和 Android 两个端不现实。我们最后采用"响应式 Web + 移动端 H5"的方式先上线,用一套 Vue 3 + Vite 的前端工程同时覆盖桌面端和手机浏览器,后续如果数据验证可行,再考虑套壳成 App 或上小程序。

前端选 Vue 而不是 React,纯粹是团队熟悉度的原因。这一点没有谁优谁劣,只有团队能不能快速上手。Vue 3 的 Composition API 在这种中后台加展示页混合的场景下,代码组织确实比 Options API 舒服很多,特别是房源详情这种带大量联动状态(当前选中图片、收藏状态、预约弹窗开关)的页面。

2.3 架构上的一个重要决策:MySQL 为主,检索端为副本

这套系统最核心的架构决策,就是把 MySQL 当作真相源(source of truth),一切业务写操作都走 MySQL,检索端里的数据只是 MySQL 的检索投影。房源审核通过、用户预约、房源下架这些操作全部直接落 MySQL,然后通过 MQ 发一个"数据变更"事件,消费者再把最新的房源文档写入检索端。

这样做的最大好处是,检索端挂了或者数据同步乱了,线上业务流程不会断,最多是搜索功能暂时不可用或结果旧一点。我们可以随时用全量重建索引的方式,从 MySQL 把数据重新灌进检索端。如果反过来让检索端当数据源,那么每一次写入都要等它返回结果,它一旦抖动,整个链路就卡死,这是绝对不能接受的。

这个"主从复制"式的架构思路,我认为是所有"外部检索组件 + 业务库"组合的通用范式。包括后面如果要接搜索推荐、接向量检索,都可以沿用这个范式,业务库永远只信任自己的那份数据。

3. 数据模型:从一张主表到一整套配套表

3.1 房源主表的字段设计

房源的字段非常多,一开始产品给的字段列表大概有四十多个,如果全部做成列,表会变得又宽又难维护,而且很多字段之间还有互斥关系。比如"电梯"只对住宅和小区别墅有意义,商铺就没这个概念;"建成年份"和"拿地时间"也不是每个录入人都愿意填。

最终我们把房源表拆成了两层:一层是核心主表,保存所有筛选高频用到的、类型统一的字段;另一层是扩展属性 JSON 字段,把低频、类型不统一的字段收进去。主表大概长这样:

CREATE TABLE property ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, city_code VARCHAR(16) NOT NULL COMMENT '城市编码', district_code VARCHAR(16) NOT NULL COMMENT '区/县编码', community_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '小区ID,0表示未关联', title VARCHAR(128) NOT NULL COMMENT '对外展示标题', address VARCHAR(256) NOT NULL COMMENT '详细地址', longitude DECIMAL(10, 6) NOT NULL COMMENT '经度', latitude DECIMAL(10, 6) NOT NULL COMMENT '纬度', price DECIMAL(12, 2) NOT NULL COMMENT '挂牌价(元)', unit_price DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT '单价(元/平)', area DECIMAL(8, 2) NOT NULL COMMENT '建筑面积(平)', bedroom_count TINYINT NOT NULL DEFAULT 0 COMMENT '室', livingroom_count TINYINT NOT NULL DEFAULT 0 COMMENT '厅', bathroom_count TINYINT NOT NULL DEFAULT 0 COMMENT '卫', orientation TINYINT NOT NULL DEFAULT 0 COMMENT '朝向:1东2南3西4北5东南6南北等', floor_no SMALLINT NOT NULL DEFAULT 1 COMMENT '所在楼层', total_floor SMALLINT NOT NULL DEFAULT 1 COMMENT '总楼层', property_type TINYINT NOT NULL DEFAULT 0 COMMENT '1住宅 2公寓 3写字楼 4商铺', decoration TINYINT NOT NULL DEFAULT 0 COMMENT '0毛坯 1简装 2精装', building_year SMALLINT NOT NULL DEFAULT 0 COMMENT '建成年份', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1待审核 2在售 3已预约 4已成交 5已下架', audit_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '审核人', audit_time DATETIME DEFAULT NULL, owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '录入人/经纪人ID', view_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '浏览量', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, ext JSON DEFAULT NULL COMMENT '扩展字段:供暖、电梯、产权年限、车位等', KEY idx_city_district_price (city_code, district_code, price), KEY idx_community (community_id), KEY idx_status_updated (status, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段表结构到今天还在用,虽然中间调过几次索引,但整体没推翻过。几个关键设计点说一下。

第一,价格和面积都用 DECIMAL 而不是 DOUBLE。房源价格是强精度要求的数据,浮点类型会出现 0.1 加 0.2 不等于 0.3 这种情况,做统计和区间过滤的时候会产生脏结果。

第二,朝向、物业类型、装修这些都用 TINYINT 编码,而不是直接存字符串。原因很简单:筛选条件要多条件组合,数字编码在索引和比较上的效率远高于字符串,而且可以避免"东南""南""东南向带阳台"这类脏数据。编码表放在配置里,前端展示由字典接口翻译。

第三,经纬度存 DECIMAL(10,6)。这个精度大概到米级,完全够房源定位使用。我们不打算用 MySQL 做地理检索,周边搜索在检索端里做,MySQL 里的经纬度只用来做唯一性判断和详情展示。

3.2 JSON 扩展字段怎么用

ext 这个 JSON 字段一开始我是有点犹豫的,因为很多人听到 JSON 字段就觉得是"偷懒、不做验证"。但 JSON 字段在 MySQL 里如果配合好用,收益很大。我们这个场景的特点是:字段多、杂、很多房源没有值。比如"供暖方式"只有北方房源在填,"是否有电梯"对平房没意义。如果全拆成列,表会膨胀到五六十个字段,大部分都是 NULL,存储浪费,代码里也要写一大坨 DTO。用 JSON 把这些低频字段收进去,结构清晰,而且 MySQL 8 支持 JSON 的路径表达式查询,真的需要按某两个扩展字段过滤时也能写出来。

需要特别注意的一个坑:JSON 字段默认不能建普通索引,如果某个 JSON 内字段突然变成高频筛选条件,就要把它从 JSON 里"提升"到主表的普通列。我们上线后经历过一次,后来把"是否有电梯"提到了主表,就是因为运营活动要按这个条件筛选,JSON 路径查询的性能撑不住。

3.3 用户行为与预约模型

除了房源表,还有几张核心的配套表:用户表、收藏表、预约带看表、浏览记录表、举报反馈表。这里挑最关键的两张讲。

收藏表的核心是唯一索引:

CREATE TABLE user_favorite ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, property_id BIGINT UNSIGNED NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_property (user_id, property_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

唯一索引不只是在数据库层面防重复,更重要的,它是"幂等取消收藏"操作的基础。用户反复点击收藏再取消,前端会连续发请求,如果没有唯一索引,可能出现同一条收藏记录插两次;而且有了唯一索引,前端可以通过一次请求的结果来更新状态,而不是靠失败重试去反转状态。

预约表要处理的就复杂一些,因为要防"同一套房同一时间段被重复预约"。

CREATE TABLE viewing_appointment ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, property_id BIGINT UNSIGNED NOT NULL COMMENT '房源ID', user_id BIGINT UNSIGNED NOT NULL COMMENT '预约人ID', appoint_time DATETIME NOT NULL COMMENT '期望看房时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认 1已确认 2已取消 3已完成', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_property_time (property_id, appoint_time, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这个唯一索引不能真正解决并发预约问题,因为在状态从"待确认"变成"已取消"之后,同一房源同一时间可以再次预约,但索引会拦住新插入。这里真正的解法是"先查后插 + 分布式锁 + 房源状态位",后面在实操章节展开。建表的时候能防的只是"同一时间、同一房源、同一状态"下的重复插入,这个兜底非常重要,尤其在接口被脚本刷的时候。

4. 核心功能的实现细节与实操要点

4.1 房源状态机:从草稿到成交的每一步

房源状态我上面定义成了:0 草稿、1 待审核、2 在售、3 已预约(暂不可约)、4 已成交、5 已下架。实际开发中,这里最重要的教训是:不要在接口里用 if/else 写状态流转。

最开始我图省事,直接就在保存接口里判断:"如果是待审核的房源,运营点确认,就把 status 改成 2"。结果第三周就出问题:运营同事在审核页对一套"草稿"状态的房源点了"通过",因为前端把按钮渲染出来了,后端又没有校验,草稿状态直接跳到在售,而草稿里的房源信息还没填完,图片都没传。这种事故表面上怪前端没有禁用按钮,本质上是后端没有把状态流转做成唯一入口。

后来我把状态流转收敛成了一个独立的服务方法:

public boolean transition(Long propertyId, int fromStatus, int toStatus, Long operatorId) { // 本质是一条受乐观锁保护的 UPDATE }

每次状态变更都必须传入"当前状态"和"目标状态",UPDATE 语句里带上AND status = #{fromStatus}。更新成功且 affected rows 为 1 才代表流转成功;如果 affected rows 为 0,说明状态已经被别人改了,直接返回冲突。这套"乐观锁状态机"方案非常轻,也足够可靠。

提示:状态机不是越复杂越好。当时我参考了一些开源项目的做法,想引入状态引擎,后来发现这种六个状态的业务,用一个方法加一张"可流转状态表"就够了。过度设计在这个项目的早期浪费了不少时间,这是我自己要检讨的地方。

4.2 多条件组合搜索:从 MySQL 到检索端的迁移过程

版本一,我用 MySQL 拼 WHERE 条件。当时房源量大概五万套,单条件查询很快,但只要两个以上条件组合,而且带了价格区间,响应时间就开始往上飙。最典型的慢查询是这样:

SELECT * FROM property WHERE city_code = '1001' AND district_code = '100112' AND property_type = 1 AND price BETWEEN 300000 AND 500000 AND area >= 80 ORDER BY updated_at DESC LIMIT 10 OFFSET 20;

这条 SQL 看起来有字段索引,但组合起来只能命中复合索引的一部分。area 和 property_type 的过滤是在排序前做的回表查找,数据一多性能就很差。我试过把所有条件都塞进一个联合索引,结果索引列太多太长,插入性能下降,而且后续加筛选条件时 SQL 特别难维护。

移到独立检索引擎之后,这个查询变成了一个标准的 bool 查询,几个 filter 加一个 sort,性能非常稳定。检索端里一份房源文档的映射大概是:

{ "property_id": 123456, "city_code": "1001", "district_code": "100112", "property_type": 1, "price": 420000, "area": 98.5, "orientation": 6, "decoration": 2, "status": 2, "tags": ["地铁房", "满五", "双卫"], "title": "XX小区 三室两厅 精装 南北通透", "publish_time": "2024-06-01T10:00:00Z", "location": {"lat": 31.23, "lon": 121.47} }

重点在于 title 和 tags 走分词匹配,其他全部用精确匹配。这样 bool filter 嵌套组合,命中缓存,单次查询在几毫秒到几十毫秒之间。内存消耗和索引体积也在可控范围,五万到十万份文档对一台 8G 内存的节点毫无压力。

4.3 周边房源的地理检索

"周边房源"这个功能是产品后来加的,一开始我以为很简单,结果踩了一天的坑。需求是这样的:用户在房源详情页可以看到"同小区及周边 3 公里内的房源",按距离排序展示。

方案一我用 MySQL 直接算两点距离:把地图上所有在售房源都查出来,用 Haversine 公式算距离再排序。这个方案对十万级数据点来说是灾难,接口直接超时。后来我看到有文章说可以先用经纬度粗筛一个正方形范围,再精确算距离,这个能优化一点,但也只是从"灾难"变成"很慢"。

方案二用检索引擎的地理距离查询。这才是正确解法。这类引擎对地理位置专门做了 geo_point 类型和地理过滤器的实现,索引预排序,查得很稳。最终查询就像这样:

{ "query": { "bool": { "filter": [ {"term": {"status": 2}}, {"geo_distance": {"distance": "3km", "location": {"lat": 31.23, "lon": 121.47}}} ] } } }

需要注意两点:第一,geo_point 字段必须在 mapping 里声明为地理点类型,不能只是存一个经纬度字符串;第二,中文文档里很多资料说地理距离用的是球面距离,这个对三公里以内的计算误差影响很小,但对跨城市定位会影响大,所以城市和地图范围要先算好。

这个功能上线后平均响应在 15ms 左右,比 MySQL 方案好了不止一个数量级。如果你的项目里有任何"周边 X 公里"的需求,趁早把数据喂给专业检索引擎或空间数据库,不要自己硬算。

4.4 并发预约与分布式锁

回到预约看房的并发问题。用户 A 和用户 B 在同一个时间段点了同一套房源的"预约看房",两个请求几乎同时到达。如果只是"先查后插":

-- 伪代码 if (不存在该时段预约) { insert into viewing_appointment(...); }

一定会出现两个请求都通过查询、然后都插入成功的竞态。虽然表上有唯一索引兜底,但唯一索引会直接导致其中一个请求报 1062 重复键错误,用户体验很差,前端拿到一个看不懂的数据库错误提示。

正确的做法是引入"预占"机制:预约动作分两步,第一步对房源加一把分布式锁,锁的粒度是"房源ID + 预约时间点";第二步在锁内完成"查重 + 插入 + 更新房源状态",最后释放锁。伪代码如下:

String lockKey = "lock:appointment:" + propertyId + ":" + appointTime; boolean locked = redis.setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (!locked) { return "该时段正在被其他用户预约,请稍后"; } try { // 业务校验:同一时段是否已有待确认/已确认的预约 // 插入预约记录 // 房源 status: 2 -> 3 } finally { redis.delete(lockKey); }

这里有几个细节一定要处理好。第一,锁一定要设置过期时间,防止业务方异常没释放导致死锁,但这个时间不能太短,否则业务还没执行完锁就过期了。第二,业务完成后主动删除锁之前,要判断这个锁是不是自己加的,可以存 value 为当前请求的唯一标识,防止删掉别人刚加的锁。第三,房源状态从 2"在售"变成 3"已预约"这个动作,也要用乐观锁,确保只有一套房源能被确认预约。

提示:分布式锁不是银弹。如果团队里已经有数据库悲观锁的使用经验,在单机数据库场景下也可以直接用它,但要注意锁范围不能太大,别把整个房源行锁住太久。我们最终选择 Redis 锁,是因为预约需要锁的粒度比较细,而且后续要扩展到多实例部署。

5. 上线前后的真实踩坑记录

5.1 业务库与检索端的数据一致性:一次漏同步事故

这里要讲一个真实翻过车的事故。上线第三周的一个早晨,运营同事反馈:一套房源在后台已经下架了,但用户在前端还能搜索到,点进去详情页报"房源不存在"。

排查过程是这样的:先看业务库,房源 status 已经是 5(下架),没问题;再看检索端,查询返回的文档里 status 还是 2。也就是说,下架动作没有触发检索端更新。

为什么?因为当时前端在下架接口里,是先调了"更新房源状态"的接口,这个接口成功了,但紧接着要发"房源变更事件"到 MQ 的那段代码,因为 MQ 连接池刚好那会儿满,抛了异常,而异常没有被正确捕获,整个请求直接返回了 500。前端看到 500 以为失败了,就弹了个错误提示,可实际上业务库里的状态已经改了。两边不一致的根源在于:"改数据库"和"发事件"不是原子的。

这个问题有三种常见解法。第一种最简单:把"发事件"放在数据库事务结束后,利用事务同步器,事务提交成功后执行发送;但事务同步器只是让发送时机更靠后,并没有保证发送成功。第二种用本地消息表:在同一事务里写业务数据和一条"待发送事件",由独立任务扫表发送,这是经典的可靠事件方案,稍微重一点。第三种用变更数据捕获:监听数据库日志的变更,把变更同步到检索端,这个最可靠但引入额外组件。

我们最后取了折中方案:本地消息表加重试任务。业务事务里多写一条事件记录,事务提交后有个定时任务扫未发送记录,发给 MQ 并标记已发送;如果发送失败,就一直重试。与此同时,检索端消费者没收到事件就不更新索引,业务库一有变更就必然有事件,一致性的窗口从"永远不一致"缩小到了"重试周期内短暂不一致",这个对房源这种低频改动的业务来说完全可接受。

5.2 深分页:业务库和检索端各踩一次

分页这个坑非常经典,但我还是要单独记录,因为很多人都没意识到数据量上去之后 OFFSET 有多危险。我们详情页的"同小区房源"列表,早期用的是LIMIT 20 OFFSET 100,房源量只有几千套时毫秒级,到三万套时接口变成了三百多毫秒,再往后直接到一秒。

原因大家都知道:OFFSET 100000是数据库先扫出来 100020 行,再丢掉前 100000 行。优化方式是使用"游标分页":以上一页最后一条记录的排序字段值作为下一页起点,而不是用 OFFSET。

-- 第2页:上一页最后一条 updated_at = '2024-06-01 10:00:00' SELECT * FROM property WHERE city_code = '1001' AND status = 2 AND (updated_at < '2024-06-01 10:00:00') ORDER BY updated_at DESC LIMIT 20;

这种分页的最大问题是"跳页"不支持,用户从第 1 页直接跳到第 5 页就没有办法用游标做。我们最后采用混合方案:前 5 页用 OFFSET 分页,因为 OFFSET 小的时候性能没压力;第 5 页之后的翻页用游标,用页码对应游标值记录来实现跳跃翻页的近似效果。

检索端那边的深分页坑也类似。默认的 from 加 size 分页最大不能超过 10000,超过会直接报错。我早期用深 from 值去分页,结果数据一多直接翻车。正规方案是用 search_after 配合时间点机制实现稳定排序,但这要求排序字段唯一或加上唯一 ID 才能保证下一页不错乱。我们最终把"同城房源列表"的滚动加载改成了 search_after 方案,跳页在检索端场景也基本放弃,UI 改成"加载更多"而不是页码,这是产品上的一种妥协,但对性能的收益是巨大的。

5.3 房源图片慢:从链路逐个排查

房源详情页图片是最影响用户体验的一环。上线初期,图片都是原始大图直接通过 CDN 加载,结果首屏出现了明显的白屏等待,在弱网环境下尤其明显。用浏览器开发者工具一看,一张图片压缩前的体积动辄 2 到 4MB,首屏要加载五张。

我们做了三层优化。

第一层是图片服务在对象存储上传时就生成多套缩略图,详情页用 640px 宽度的 WebP 图,列表页用 320px 的 WebP 图,原图只在用户点击查看大图时才加载。这一步把首屏图片体积从十几 MB 降到几百 KB。

第二层是开启 HTTP 缓存。CDN 的 Cache-Control 设置为图片 30 天,列表接口的图片 URL 后面带版本参数,房源图片更新后版本号变化就能刷新缓存。前端用懒加载,列表页屏幕上未出现的图片都打上懒加载标记,保证滚动才加载。

第三层是骨架屏。详情页给图片区一个固定宽高比的骨架,图片异步加载完成后填入,避免布局跳动,也避免白屏带来的"卡死了"错觉。这个纯粹是体验优化,但对用户留存的影响非常直观,对比测试里加载完成率提升了差不多 10 个点。

提示:出现图片加载慢的问题时,先确认瓶颈在"网络传输"还是"服务器生成"。用图片大小除以加载时间就能估算速率,如果速率远低于带宽,大概率是源站响应慢或 CDN 回源问题,而不是压缩策略问题。先量化,再动手,别一上来就压图。

5.4 高频问题速查表

这里把我这一路遇到过、以及身边同事问得最多的问题整理成一张速查表,省得大家反复排查相同的问题。

现象可能原因排查顺序/解法
搜索明明有房源但检索端查不到1. 房源未发布 2. 同步事件丢失 3. 索引未重建先查业务库状态;再看事件表是否有未发送记录;最后做全量重建
房源详情与列表状态不一致缓存了旧数据检查缓存 key 的过期时间,看是主动失效失败还是被动过期未设置
列表接口越来越慢分了太深的页改用游标分页,避免大 OFFSET
同一套房源被重复预约并发竞态确认是否有锁;锁的粒度是否够细;房源乐观锁是否生效
上传图片后前端还是旧图CDN 缓存未刷新URL 加版本号参数,或调用 CDN 刷新接口
地域筛选结果不对行政区域编码配置错误检查城市/区域编码表,特别是新区代码与名称映射
高峰时段搜索超时查询过重或无缓存看检索端慢查询日志,检查过滤条件是否有高开销条件

这张表是我后来做团队交接文档的底稿,新同学遇到问题先对表再动手,能省掉不少来回确认的时间。

6. 压测、优化与最终效果

6.1 压测准备与两个瓶颈

上线前我做了一轮压测,用的工具是开源压测工具,模拟同时在线 500 用户浏览房源列表与详情。压测结果出来之后,两个瓶颈特别明显。

第一个是列表页接口在并发 200 以上时 P95 从 50ms 飙升到 600ms。用慢查询日志定位到,问题出在每个列表请求都对浏览数字段做了一次 UPDATE,即使只是浏览列表也累加浏览量。这是一个典型的"写放大"问题:列表页完全不应该写库。我们最终只在详情页做浏览量的更新,而且用 Redis 的 INCR 攒在内存里,每 10 分钟批量刷一次 MySQL。

第二个是检索端堆内存飙升。查了监控发现,某条聚合查询里用的桶聚合数量没有限制,一个城市的数据被拆成了几千个桶。修复方式很简单:给聚合加数量限制,桶数量控制在需求范围内的最大值。压测的时候,不要把测试数据造得太假,桶数量是跟真实基数走的,测试基数要和预期线上量级接近才有意义。

6.2 两条值得记录的 SQL 优化

这里挑两条上线后真正优化过、并且收益明显的 SQL 手记。

第一条是经纪人后台待办列表的统计 SQL。运营后台首页要展示"待审核房源 12 条,待确认预约 5 条,待处理举报 3 条",原实现是三次 COUNT,每次 COUNT 都因为带 where 条件且没索引而扫描效率低下。后来我给三张核心表的 status 字段都加了联合索引,COUNT 走索引覆盖,后台首页从 800ms 降到 30ms 以内。COUNT 要扫索引而不是扫表,说起来简单,但在团队里经常被忽略。

第二条是"同小区房源推荐"的查询。原 SQL 是按小区和状态过滤,然后按浏览量排序取前六条,能命中索引但排序还需要额外排序。优化方式是建立包含排序字段的组合索引,让索引天然按浏览量排好序,ORDER BY 就能直接走索引顺序,不需要额外的文件排序操作。这个优化的收益在房源多的小区非常明显,尤其在热门小区。

6.3 最终效果复盘

项目上线后的实际数据:房源总量约 6.8 万套,检索端单节点 8G 堆内存,搜索接口平均响应 18ms,P95 在 45ms 以内。列表接口加了缓存后平均 8ms。预约功能上线至今没有出现并发重复预约的事故。运营后台的最慢接口也从最初的 800ms 优化到了 60ms 以内。对一个六人小团队、两个多月交付的项目来说,这个性能数据我们是满意的。

相比性能,我更看重的是系统在真实流量下的稳定性。上线三个月里,没有因为并发或数据不一致导致一次用户主动投诉,这个和前面说的"业务库为真相源 + 消息表兜底 + 状态机收敛流转"这套设计是强相关的。技术选型可以不时髦,但核心链路必须稳。

7. 一些实际操作中的体会

写到最后,分享几条这个项目做完后我的真实感受,可能比某些技术细节对大家的帮助更大。

第一,做这类信息平台,一半精力要花在"状态管理"上,而不是"功能开发"上。房源状态、预约状态、审核状态,凡是用户能感知的状态,都要有唯一入口和清楚的生命周期。我在这个项目里前两周写功能,后两周几乎都在修状态相关的问题,这是我的一个失误,希望读者不要走同样的弯路。

第二,多条件检索这类需求,如果预见到数据量会过万,建议一开始就设计好"业务库 + 检索引擎"的双层结构,不要等慢查询打上门才迁移。迁移的成本永远高于最开始多花一天设计索引和映射的成本。而我个人最喜欢的架构,就是本文反复说的"业务库为唯一真相源、检索引擎为投影",这个思路可以复用到很多场景。

第三,团队小的时候,约定比技术重要。"状态流转必须走后端唯一方法""发事件必须走本地消息表""分页超过 N 页必须改游标",这些约定写在项目文档里,比任何公共组件都更能防止事故。

第四,如果让我重新做一次这个项目,我会在初期就跟产品对齐一个事情:房源字段不是越多越好。四十多个字段里大概有十个高频字段,剩下全是低频。把高频字段做成列、低频做成扩展属性,这个判断越早做,后面重构越少。

这就是 REA 这个项目从立项到稳定运行的大致全貌。技术栈不复杂,方案也算不上多先进,但每一步选择的理由,我至今都记得清清楚楚。如果你正在做一个类似的"信息展示 + 预约转化"类平台,希望这篇记录能帮你少踩几个坑。要是有不同看法或者更好的方案,欢迎指出来,技术这行总能在碰撞里学到新东西。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 16:20:51

OPC DA客户端X64实战:从解压到读写PLC点位

简介&#xff1a;这份资源是面向工业自动化领域开发者的 OPC DA 客户端开发包&#xff0c;基于 COM/DCOM 技术实现&#xff0c;适用于在 64 位 Windows 环境下使用 Visual Studio 2013 构建与 OPC 服务器通信的应用程序。包内包含客户端动态库、头文件、示例工程与迁移说明&…

作者头像 李华
网站建设 2026/10/20 16:20:23

Spring AI + MCP 构建 Java Agent 实战:从架构到踩坑全解析

先说结论&#xff1a;Spring AI MCP 这套组合&#xff0c;是我最近半年用下来觉得最“稳”的 Java 侧 Agent 落地方式。它解决的核心问题不是“把模型接进来”&#xff0c;而是“怎么让模型安全、可控、可扩展地调用真实业务能力”。如果你正在 Java 技术栈里做 AI Agent、智能…

作者头像 李华
网站建设 2026/10/20 16:20:21

ESP-IDF V6.0工具目录迁移:自定义IDF_TOOLS_PATH,彻底解决C盘爆红问题

如果你最近升级到ESP-IDF V6.0&#xff0c;大概率会撞上同一个坑&#xff1a;装完一查&#xff0c;OpenOCD、xtensa工具链、Python环境全都被塞进了用户目录下的.espressif文件夹。我的C盘是256G的SSD&#xff0c;平时装完一堆开发套件已经快满了&#xff0c;再来一个ESP-IDF的…

作者头像 李华
网站建设 2026/10/20 16:19:51

放弃本地虚拟机,用VSCode Remote-SSH实现远程Linux开发

1. 为什么我最终放弃了本地虚拟机&#xff0c;转向远程开发如果你日常在Windows上写代码&#xff0c;但代码最终要跑在Linux服务器上&#xff0c;那你一定经历过这种割裂感&#xff1a;本地用着顺手的编辑器&#xff0c;代码写完却要手动scp上传&#xff0c;或者用Xshell这类终…

作者头像 李华
网站建设 2026/10/20 16:19:26

WinRE.wim与boot.wim区别解析及离线修改实操指南

搞系统维护的人&#xff0c;十个里有八个都跟这两个文件打过照面&#xff0c;但真能说清楚"WinRE.wim"和"boot.wim"到底有什么区别、各自该怎么处理的&#xff0c;恐怕不到一半。我干这行这些年&#xff0c;见过太多人把这两个文件混为一谈&#xff0c;结果…

作者头像 李华
网站建设 2026/10/20 16:18:34

基于cve-search搭建漏洞管理平台:从部署到安全运营闭环

最近接了个任务&#xff0c;帮某公司搭一套内部的漏洞管理平台。他们之前处理安全通告的方式&#xff0c;说实话挺原始的&#xff1a;安全团队收到一条CVE推送&#xff0c;就开始查内部资产清单&#xff0c;人工比对哪些系统用了受影响组件&#xff0c;再逐个发邮件催修复。等一…

作者头像 李华