资讯动态

仿贝壳开源房产系统实战:数据建模、多条件筛选与搜索优化

发布时间:2026/10/13 8:35:45 来源:尧图企业网站定制
简介这是一套面向房产中介创业者、房产门户运营方及二次开发者的百分百开源房产系统源码可一站式搭建类似贝壳、链家、58、安居客的房产电商平台。系统覆盖PC端与手机端内置新房、二手房、出租房、小区、问答等模块并支持内外网ERP与多区域分站运营通过子域名绑定即可快速跨区域扩张同时兼容自主运营与加盟运营相结合的模式。资源包共5701个文件以js、html、php、css等前后端源码为主体辅以gif、png、jpg等图片素材及less、scss样式文件另有json、yml、sql等配置与数据库脚本压缩包约34.66MB目录结构完整便于按模块检索与二次开发。目前已有2376人学习下载适合需要快速搭建房产信息平台、研究房产电商系统架构或进行定制化改造的开发者参考可从中获取多端业务逻辑、分站权限分配与加盟管理思路。1. 仿贝壳百分百开源房产系统网站从零搭一套能跑通的房源平台二手房挂牌、房源详情页、地图找房、经纪人带看记录——这些模块在房产系统里几乎是标配。但真正自己动手搭一套「仿贝壳百分百开源房产系统网站」时很多人卡在第一步数据模型怎么设计房源、小区、经纪人、带看记录之间怎么关联前端筛选条件怎么和后端索引对上。我见过不少团队用通用 CMS 硬改结果房源列表页加载三秒起步筛选组合一多直接超时。这套系统的核心价值在于把房产交易里最重的几个数据实体房源、小区、商圈、经纪人、用户、带看用合理的关系模型串起来再配一套能扛住多条件组合筛选的查询层。适合两类人一是想自建房产平台的中小团队二是想拿一个真实业务场景练全栈的开发者。下面按「数据建模 → 后端接口 → 前端筛选 → 搜索优化 → 避坑」的顺序拆开讲每一步都给可复现的代码和参数。2. 房源数据模型怎么设计六张核心表与三个关系陷阱2.1 为什么不能把房源信息塞进一张宽表房产系统最典型的翻车点就是一开始图省事把房源的所有属性——面积、户型、楼层、朝向、装修、小区名、商圈、地铁距离、挂牌价、成交价——全塞进一张house表。前两周跑得挺顺等到要按「商圈 户型 价格区间 地铁距离」组合筛选时查询条件一多索引根本建不过来。正确的做法是拆成六张核心表house房源主表、community小区表、district区域商圈表、agent经纪人表、user用户表、appointment带看记录表。房源主表只存和房源本身强相关的字段小区和商圈信息通过外键关联。这样小区改名、商圈调整时不用动房源数据。-- 房源主表只存房源自身属性 CREATE TABLE house ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, community_id BIGINT NOT NULL COMMENT 关联小区, agent_id BIGINT NOT NULL COMMENT 维护经纪人, layout VARCHAR(50) COMMENT 户型如3室2厅, area DECIMAL(8,2) COMMENT 建筑面积, floor INT COMMENT 所在楼层, total_floor INT COMMENT 总楼层, orientation VARCHAR(20) COMMENT 朝向, decoration VARCHAR(20) COMMENT 装修程度, price DECIMAL(12,2) COMMENT 挂牌总价, unit_price DECIMAL(10,2) COMMENT 单价, status TINYINT DEFAULT 1 COMMENT 1在售 2已售 3下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_community (community_id), INDEX idx_status_price (status, price), INDEX idx_agent (agent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表语句里idx_status_price是给「在售房源按价格排序」这个最高频查询准备的联合索引。注意status放前面因为筛选时状态几乎必带而价格区间是范围查询放后面才能用上索引。unit_price单独存一列而不是每次用price/area算是为了让「按单价排序」也能走索引。2.2 小区表和区域表的层级关系小区和区域的关系是房产系统里最容易设计错的地方。常见错误是小区直接挂一个「商圈」字段但实际业务里一个小区可能横跨两个商圈一个商圈又属于一个行政区。我一般会拆成district行政区和biz_area商圈两张表小区通过biz_area_id关联商圈商圈再关联行政区。CREATE TABLE district ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 行政区名, city_code VARCHAR(20) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE biz_area ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 商圈名, district_id BIGINT NOT NULL, INDEX idx_district (district_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE community ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, biz_area_id BIGINT NOT NULL, address VARCHAR(200), build_year INT COMMENT 建成年份, total_house INT COMMENT 总户数, avg_price DECIMAL(10,2) COMMENT 小区均价定时任务更新, INDEX idx_biz_area (biz_area_id), INDEX idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;community表里的avg_price是冗余字段由定时任务每天凌晨根据在售房源重新计算。这样做的好处是小区列表页不用每次去聚合房源表直接读缓存字段。代价是数据有延迟但对房产场景来说小区均价延迟一天完全可接受。2.3 带看记录表时间线和状态机带看记录是房产系统里状态流转最复杂的部分。一条带看记录要经历「预约 → 确认 → 带看中 → 已完成 → 已取消」几个状态而且用户和经纪人都可能取消。设计时不要用多个布尔字段is_confirmed、is_cancelled而是用一个status字段加状态机。CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, house_id BIGINT NOT NULL, user_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, appoint_time DATETIME NOT NULL COMMENT 预约带看时间, status TINYINT DEFAULT 1 COMMENT 1待确认 2已确认 3已完成 4已取消, cancel_reason VARCHAR(200), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_agent_time (agent_id, appoint_time), INDEX idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_agent_time这个联合索引是给经纪人查「我明天有哪些带看」用的按经纪人 ID 过滤后按时间排序走一个索引就够。状态字段用 TINYINT 而不是 ENUM是因为后续加状态时改 ENUM 要锁表TINYINT 加个注释就行。3. 后端接口怎么写房源列表分页与多条件筛选3.1 用 MyBatis 动态 SQL 拼筛选条件房源列表接口是整个系统压力最大的地方。用户可能同时传商圈、价格区间、户型、朝向、装修、关键词六个条件还可能按价格或单价排序。用 MyBatis 的where和if动态拼 SQL 是最稳的做法但要注意防止条件为空时全表扫描。select idselectHousePage resultMapHouseVOMap SELECT h.id, h.title, h.layout, h.area, h.price, h.unit_price, c.name AS community_name, b.name AS biz_area_name FROM house h JOIN community c ON h.community_id c.id JOIN biz_area b ON c.biz_area_id b.id where h.status 1 if testbizAreaId ! null AND c.biz_area_id #{bizAreaId} /if if testminPrice ! null AND h.price gt; #{minPrice} /if if testmaxPrice ! null AND h.price lt; #{maxPrice} /if if testlayout ! null and layout ! AND h.layout #{layout} /if if testorientation ! null and orientation ! AND h.orientation #{orientation} /if if testkeyword ! null and keyword ! AND c.name LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY choose when testsort price_asch.price ASC/when when testsort price_desch.price DESC/when when testsort unit_price_asch.unit_price ASC/when otherwiseh.created_at DESC/otherwise /choose LIMIT #{offset}, #{pageSize} /select这段 SQL 的关键点h.status 1写死在where里而不是放在if里保证永远只查在售房源避免条件全空时把下架房源也扫出来。ORDER BY用choose而不是直接拼字符串防止 SQL 注入。LIMIT的offset由前端传页码、后端计算不要在前端算好传过来否则容易被改。3.2 分页参数与深分页问题分页参数一般用page和pageSize后端算offset (page - 1) * pageSize。pageSize要设上限我一般限制最大 50防止有人传pageSize10000把数据库拖垮。深分页是另一个坑。当用户翻到第 100 页时LIMIT 9900, 20要先扫描 9900 行再丢掉性能急剧下降。房产系统里用户很少翻那么深但爬虫会。常见做法是限制最大页码比如只允许翻到前 50 页或者用「上一页最后一条的 ID」做游标分页。// 游标分页传 lastId 而不是 page public ListHouseVO selectByCursor(Long lastId, int pageSize, HouseQuery query) { if (lastId null) { lastId Long.MAX_VALUE; } // SQL 里加 AND h.id #{lastId} ORDER BY h.id DESC LIMIT #{pageSize} return houseMapper.selectByCursor(lastId, pageSize, query); }游标分页的代价是不能跳页只能一页页往下翻。房产列表页通常就是无限滚动所以这个代价可以接受。如果产品坚持要跳页那就限制最大页码别硬扛深分页。3.3 房源详情接口的缓存策略房源详情页的 QPS 远高于列表页因为每个用户点进详情都会请求一次。详情数据变化不频繁适合加缓存。我一般用 Redis 缓存房源详情key 是house:detail:{id}过期时间设 10 分钟。public HouseDetailVO getDetail(Long houseId) { String key house:detail: houseId; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseObject(cached, HouseDetailVO.class); } HouseDetailVO detail houseMapper.selectDetailById(houseId); if (detail ! null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(detail), 10, TimeUnit.MINUTES); } return detail; }缓存更新策略用「删除缓存」而不是「更新缓存」因为更新缓存时如果并发写可能写入旧值。房源信息变更时直接redisTemplate.delete(key)下次请求自然回源。注意缓存穿透问题如果查一个不存在的房源 ID每次都打到数据库。解决办法是缓存空值设一个较短的过期时间比如 1 分钟。4. 前端筛选与地图找房条件联动和性能取舍4.1 筛选条件的状态管理前端筛选区通常有商圈、价格、户型、更多筛选四个维度。用户点选后要立刻刷新列表同时 URL 要同步方便分享和回退。用 Vue 或 React 都可以核心是把筛选条件统一成一个对象任何变更都走同一个fetchList方法。// 筛选条件统一对象 const filters reactive({ bizAreaId: null, minPrice: null, maxPrice: null, layout: , orientation: , sort: default, page: 1 }); // 任何筛选变更都调这个 async function fetchList() { const params { ...filters }; // 去掉空值避免后端拼无用条件 Object.keys(params).forEach(k { if (params[k] null || params[k] ) delete params[k]; }); const res await axios.get(/api/house/list, { params }); list.value res.data.records; total.value res.data.total; } // 监听筛选变化重置页码 watch(() ({ ...filters }), () { filters.page 1; fetchList(); }, { deep: true });这段代码里watch深度监听filters任何字段变化都触发fetchList同时把page重置为 1。注意watch里用{ ...filters }展开是为了让 watch 能检测到对象内部变化。实际项目里更推荐用watchEffect或手动在每次筛选点击时调fetchList避免深度监听带来的性能开销。4.2 地图找房聚合与按视野查询地图找房是房产系统的招牌功能。核心逻辑是地图移动或缩放后把当前视野的经纬度范围传给后端后端返回该范围内的房源聚合点。聚合点按网格划分每个网格显示房源数量放大到一定级别后显示具体房源。// 地图移动结束后触发 map.on(moveend, () { const bounds map.getBounds(); const params { minLng: bounds.getWest(), maxLng: bounds.getEast(), minLat: bounds.getSouth(), maxLat: bounds.getNorth(), zoom: map.getZoom() }; fetchMapHouses(params); }); async function fetchMapHouses(params) { const res await axios.get(/api/house/map, { params }); // res.data 是聚合点数组每个点有 lng, lat, count renderMarkers(res.data); }后端按zoom决定聚合粒度zoom 小于 12 时按商圈聚合12 到 15 按网格聚合大于 15 返回具体房源。网格聚合可以用 geohash 实现把经纬度转成 geohash 字符串按前缀分组统计。-- 按 geohash 前 5 位聚合 SELECT SUBSTRING(geohash, 1, 5) AS grid, COUNT(*) AS cnt, AVG(lng) AS center_lng, AVG(lat) AS center_lat FROM house WHERE status 1 AND lng BETWEEN #{minLng} AND #{maxLng} AND lat BETWEEN #{minLat} AND #{maxLat} GROUP BY gridgeohash字段在建房源时就算好存进去避免每次查询时实时计算。SUBSTRING的长度根据 zoom 调整zoom 越小取的前缀越短聚合粒度越粗。4.3 列表和地图的数据同步列表和地图同时展示时用户在地图上点一个聚合点列表要滚动到对应房源列表滚动时地图要高亮对应标记。这个联动用「选中房源 ID」作为桥梁。const selectedId ref(null); // 地图点标记 function onMarkerClick(houseId) { selectedId.value houseId; scrollToList(houseId); } // 列表项进入视口 function onListItemVisible(houseId) { if (selectedId.value ! houseId) { selectedId.value houseId; map.panTo(houseMap[houseId]); } }注意不要双向无限触发地图点标记触发列表滚动列表滚动又触发地图移动地图移动又触发列表滚动。解决办法是加一个isSyncing标志位同步过程中忽略另一侧的事件。5. 搜索与排序优化从 LIKE 到全文索引的演进5.1 关键词搜索为什么不能用 LIKE %关键词%房源标题和小区名的关键词搜索如果用LIKE %关键词%前置百分号会让索引失效数据量上到十万级就明显变慢。房产系统里用户搜「某某花园」「某某路」的频率很高必须换方案。常见做法是用 MySQL 的全文索引FULLTEXT但中文分词需要配 ngram 解析器。建索引时指定WITH PARSER ngram查询用MATCH ... AGAINST。ALTER TABLE community ADD FULLTEXT INDEX ft_name (name) WITH PARSER ngram; SELECT id, name FROM community WHERE MATCH(name) AGAINST(#{keyword} IN BOOLEAN MODE) LIMIT 20;ngram 默认 token size 是 2也就是按两个字切分。搜「花园」能命中「某某花园」但搜单个字「园」不行。如果产品要求单字搜索要把ngram_token_size设为 1但索引会变大很多。我一般保持默认 2引导用户至少输入两个字。5.2 排序字段的索引设计房源列表支持按价格、单价、发布时间排序。如果每个排序都建一个索引写入时会很慢。实际业务里排序几乎总是和筛选条件一起用所以建联合索引更有效。排序方式推荐索引说明默认发布时间idx_status_created (status, created_at)在售房源按时间倒序价格升序idx_status_price (status, price)在售房源按价格升序单价升序idx_status_unit_price (status, unit_price)在售房源按单价升序商圈价格idx_community_price (community_id, price)先按小区过滤再排序注意联合索引里status放最前面因为所有查询都带status 1。如果某个排序方式查询频率很低可以不建索引让它在数据量小的时候走文件排序别为了低频查询拖慢写入。5.3 搜索结果的相关性排序关键词搜索的结果不能只按时间排否则搜「某某花园」时标题里带「某某花园」的房源应该排在前面。MySQL 全文索引的MATCH会返回一个相关性分数可以拿来排序。SELECT h.id, h.title, c.name AS community_name, MATCH(c.name) AGAINST(#{keyword} IN BOOLEAN MODE) AS score FROM house h JOIN community c ON h.community_id c.id WHERE h.status 1 AND MATCH(c.name) AGAINST(#{keyword} IN BOOLEAN MODE) ORDER BY score DESC, h.created_at DESC LIMIT 20;score是全文索引算出的相关性越高越相关。把score放排序第一位时间放第二位这样既保证相关性又保证同等相关性下新发布的房源靠前。注意MATCH和AGAINST里的关键词要一致否则 score 算不出来。6. 避坑与排查房源系统上线后最容易翻车的五件事6.1 房源列表页加载慢但数据库 CPU 不高现象列表页打开要三到五秒查数据库监控发现 CPU 只有 20%慢查询日志里也没有明显慢 SQL。原因大概率是 N1 查询。列表接口返回 20 条房源每条房源又去查一次小区信息、一次经纪人信息总共 60 次查询。每次查询都很快但加起来就慢了。解决用 JOIN 一次性把小区和经纪人信息查出来或者在 MyBatis 里配collection做嵌套查询。更彻底的做法是列表接口只返回小区 ID前端拿到列表后再批量请求小区信息用WHERE id IN (...)一次查完。6.2 地图找房返回的聚合点数量对不上现象地图上显示的聚合点数字加起来和列表页的总数不一致。原因地图查询和列表查询用了不同的过滤条件。列表查询带了「在售」状态地图查询忘了带或者地图查询的经纬度范围比实际视野大了一圈。解决把地图查询和列表查询的过滤条件抽成一个公共方法两边都调同一个方法拼条件。经纬度范围用地图 API 返回的bounds直接传不要自己加减偏移量。6.3 带看记录状态流转错乱现象用户取消了带看但经纪人那边还显示「待确认」或者带看已完成用户还能点取消。原因状态更新没有加乐观锁两个请求同时更新同一条记录后写的覆盖了先写的。或者前端没根据状态隐藏按钮用户点了不该点的操作。解决更新状态时加WHERE status #{oldStatus}更新影响行数为 0 就说明状态已被改过返回提示让用户刷新。前端根据当前状态决定显示哪些按钮但后端必须再校验一次不能只靠前端。6.4 小区均价定时任务跑完后数据没更新现象凌晨跑了更新小区均价的定时任务但白天看小区列表均价还是旧的。原因小区列表加了缓存定时任务更新了数据库但没清缓存。或者定时任务的事务没提交数据根本没写进去。解决定时任务更新完数据库后主动删除相关小区的缓存 key。如果小区数量多可以用redisTemplate.delete(keys)批量删或者给缓存设一个较短的过期时间兜底。事务问题检查Transactional注解有没有加在 public 方法上以及异常有没有被吞掉。6.5 全文索引搜不到刚发布的房源现象新发布的房源标题里有关键词但搜索搜不到。原因MySQL 全文索引有延迟InnoDB 的全文索引不是实时更新的新插入的数据要等索引合并后才能搜到。或者 ngram 分词把关键词切错了。解决对实时性要求高的搜索不要直接用 MySQL 全文索引改用外部搜索引擎或者用「数据库 LIKE 全文索引」双查LIKE 查最近一小时的数据全文索引查历史数据。ngram 分词问题可以用SELECT * FROM INFORMATION_SCHEMA.INNODB_FT_INDEX_TABLE看实际切出来的词。7. 用 Docker Compose 一键拉起整套环境前面几章把数据模型、接口、前端筛选、搜索优化和避坑都过了一遍。最后落到一个具体技巧怎么用 Docker Compose 把 MySQL、Redis、后端和前端一次性拉起来方便本地复现和演示。version: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: house123 MYSQL_DATABASE: house_db command: --ngram_token_size2 --character-set-serverutf8mb4 ports: - 3306:3306 volumes: - ./sql:/docker-entrypoint-initdb.d - mysql_data:/var/lib/mysql redis: image: redis:7-alpine ports: - 6379:6379 backend: build: ./backend depends_on: - mysql - redis environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/house_db?useUnicodetruecharacterEncodingutf8 SPRING_REDIS_HOST: redis ports: - 8080:8080 frontend: build: ./frontend depends_on: - backend ports: - 3000:3000 volumes: mysql_data:这个 compose 文件里mysql的command加了--ngram_token_size2保证全文索引按两字分词。volumes把./sql挂到初始化目录容器第一次启动时会自动执行里面的建表脚本。backend和frontend用depends_on控制启动顺序但注意depends_on只保证容器启动顺序不保证服务就绪后端连数据库可能要加重试。启动命令就一行docker compose up -d --build第一次跑会拉镜像、编译前后端大概三到五分钟。起来之后访问localhost:3000就能看到房源列表页。如果要改数据库结构改./sql里的脚本然后docker compose down -v删掉数据卷重新来。我自己的习惯是每次改完数据模型先在本地用这套 compose 跑一遍完整流程——建表、插测试数据、调列表接口、调地图接口、搜关键词——确认没问题再提交。血泪经验是千万别在本地手动装 MySQL 和 Redis版本差异和配置差异会让你在「明明本地能跑」和「服务器上跑不起来」之间反复横跳。Docker Compose 把环境差异抹平了省下来的时间够你多调两个接口。这套方案值不值得做如果你要搭一个房源平台或者想拿一个真实业务场景练全栈它覆盖了房产系统 80% 的核心逻辑剩下的 20% 是支付、合同、IM 这些可以按需扩展的模块。先把房源、小区、带看这三块跑通后面的功能都是在这套数据模型上叠。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑