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 小区ID0表示未关联, 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段表结构到今天还在用虽然中间调过几次索引但整体没推翻过。几个关键设计点说一下。第一价格和面积都用 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;唯一索引不只是在数据库层面防重复更重要的它是幂等取消收藏操作的基础。用户反复点击收藏再取消前端会连续发请求如果没有唯一索引可能出现同一条收藏记录插两次而且有了唯一索引前端可以通过一次请求的结果来更新状态而不是靠失败重试去反转状态。预约表要处理的就复杂一些因为要防同一套房同一时间段被重复预约。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个唯一索引不能真正解决并发预约问题因为在状态从待确认变成已取消之后同一房源同一时间可以再次预约但索引会拦住新插入。这里真正的解法是先查后插 分布式锁 房源状态位后面在实操章节展开。建表的时候能防的只是同一时间、同一房源、同一状态下的重复插入这个兜底非常重要尤其在接口被脚本刷的时候。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 堆内存搜索接口平均响应 18msP95 在 45ms 以内。列表接口加了缓存后平均 8ms。预约功能上线至今没有出现并发重复预约的事故。运营后台的最慢接口也从最初的 800ms 优化到了 60ms 以内。对一个六人小团队、两个多月交付的项目来说这个性能数据我们是满意的。相比性能我更看重的是系统在真实流量下的稳定性。上线三个月里没有因为并发或数据不一致导致一次用户主动投诉这个和前面说的业务库为真相源 消息表兜底 状态机收敛流转这套设计是强相关的。技术选型可以不时髦但核心链路必须稳。7. 一些实际操作中的体会写到最后分享几条这个项目做完后我的真实感受可能比某些技术细节对大家的帮助更大。第一做这类信息平台一半精力要花在状态管理上而不是功能开发上。房源状态、预约状态、审核状态凡是用户能感知的状态都要有唯一入口和清楚的生命周期。我在这个项目里前两周写功能后两周几乎都在修状态相关的问题这是我的一个失误希望读者不要走同样的弯路。第二多条件检索这类需求如果预见到数据量会过万建议一开始就设计好业务库 检索引擎的双层结构不要等慢查询打上门才迁移。迁移的成本永远高于最开始多花一天设计索引和映射的成本。而我个人最喜欢的架构就是本文反复说的业务库为唯一真相源、检索引擎为投影这个思路可以复用到很多场景。第三团队小的时候约定比技术重要。状态流转必须走后端唯一方法发事件必须走本地消息表分页超过 N 页必须改游标这些约定写在项目文档里比任何公共组件都更能防止事故。第四如果让我重新做一次这个项目我会在初期就跟产品对齐一个事情房源字段不是越多越好。四十多个字段里大概有十个高频字段剩下全是低频。把高频字段做成列、低频做成扩展属性这个判断越早做后面重构越少。这就是 REA 这个项目从立项到稳定运行的大致全貌。技术栈不复杂方案也算不上多先进但每一步选择的理由我至今都记得清清楚楚。如果你正在做一个类似的信息展示 预约转化类平台希望这篇记录能帮你少踩几个坑。要是有不同看法或者更好的方案欢迎指出来技术这行总能在碰撞里学到新东西。