仿贝壳房产系统实战:房源数据模型与筛选性能优化
简介这是一套面向房产中介创业者、房产门户运营方及二次开发者的开源房产系统网站源码主打仿贝壳、链家、58同城等平台的业务模式可一站式搭建新房、二手房、出租房、小区、问答等多场景房产电商平台。系统同时覆盖PC端与手机端内置内外网ERP与外网运营模块支持多区域分站、连锁加盟权限分配及广告位运营适合希望快速上线自有房产平台的中小团队。资源包共约2000个文件压缩后117.23MB以js、html、php、css、json等前后端代码为主辅以png、gif、jpg等图片素材及md、yml、sql等配置与文档文件结构完整、技术方案成熟。目前已有2346人学习下载。源码开放程度高便于按需裁剪功能模块、二次开发为其他电商产品也能帮助开发者理解房产平台从房源发布、在线查询到订单接收的完整链路具备较高的参考与复用价值。1. 仿贝壳房少房产系统网站从房源列表到成交漏斗一套能跑通的最小闭环挂出一套房源用户点进来翻了三页然后走了——这是很多房产系统上线后最真实的写照。仿贝壳房少房产系统网站这个方向核心不是把页面做得像谁而是把「房源展示 → 筛选匹配 → 留资转化」这条链路跑通。它适合两类人一类是手里有区域房源数据、想快速搭一个可演示可运营的房产平台另一类是想练手一个带真实业务复杂度的全栈项目房源字段多、筛选条件杂、状态流转绕比增删改查的 Demo 有嚼头。我做过几版类似的系统踩过的坑集中在数据模型和筛选性能上这篇就把这套东西拆开讲清楚。2. 房源数据模型怎么设计才不返工房产系统的地基是房源表但很多人一上来就建一张大宽表字段堆到五六十个后面加一个「满五唯一」或者「近地铁」的标签就得改表结构。我一般会把房源拆成三层基础信息、交易属性、扩展标签。基础信息是小区、户型、面积、楼层这些不太会变的交易属性是价格、税费、产权年限、挂牌状态扩展标签是地铁、学区、装修、朝向这类可枚举可增删的。三层分开改标签不动主表查询时按需 join。2.1 核心表结构与字段取舍先看房源主表我习惯用 MySQL 8 起步字符集 utf8mb4价格用 int 存「分」避免浮点误差。下面是最小可用的一张表字段控制在二十个以内够撑起列表页和详情页。CREATE TABLE house ( id bigint unsigned NOT NULL AUTO_INCREMENT, title varchar(120) NOT NULL COMMENT 房源标题, community_id int unsigned NOT NULL COMMENT 小区ID, city_code varchar(12) NOT NULL COMMENT 城市编码, district_code varchar(12) NOT NULL COMMENT 区域编码, layout varchar(20) NOT NULL COMMENT 户型如3室2厅, room tinyint unsigned NOT NULL COMMENT 室, hall tinyint unsigned NOT NULL COMMENT 厅, area decimal(8,2) NOT NULL COMMENT 建筑面积平米, floor smallint NOT NULL COMMENT 所在楼层, total_floor smallint NOT NULL COMMENT 总楼层, orientation tinyint unsigned NOT NULL DEFAULT 0 COMMENT 朝向枚举, decoration tinyint unsigned NOT NULL DEFAULT 0 COMMENT 装修枚举, total_price int unsigned NOT NULL COMMENT 总价单位分, unit_price int unsigned NOT NULL COMMENT 单价单位分/平米, status tinyint unsigned NOT NULL DEFAULT 1 COMMENT 1在售 2预定 3已售 4下架, tags json DEFAULT NULL COMMENT 扩展标签数组, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_city_district (city_code,district_code), KEY idx_status_price (status,total_price), KEY idx_community (community_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT房源主表;逻辑说明total_price和unit_price都用 int 存分前端展示时除以 100这样排序和范围查询不会因为 decimal 比较产生隐式转换。tags用 JSON 字段而不是关联表是因为标签查询频率远低于列表筛选JSON 里存[near_subway,top_school]这种字符串数组配合 MySQL 8 的JSON_CONTAINS够用。索引上idx_city_district覆盖区域筛选idx_status_price覆盖「在售 价格区间」这个最高频组合。参数说明area用 decimal(8,2) 是因为面积要精确到小数点后两位且参与区间筛选floor用 smallint 而不是 tinyint因为超高层总楼层可能过百所在楼层也可能到 99 以上。status用 tinyint 枚举别用字符串字符串状态在索引里占空间且比较慢。2.2 小区表与区域表的关联策略小区表单独建存小区名、区域、经纬度、建成年代、均价。区域表用编码而不是自增 ID因为区域数据往往来自第三方编码稳定。关联时我一般不在房源表冗余小区名而是列表查询时 join 小区表取名字但会在房源表冗余city_code和district_code因为区域筛选是列表页第一道过滤冗余这两个字段能避免 join 区域表。CREATE TABLE community ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(80) NOT NULL, district_code varchar(12) NOT NULL, address varchar(200) DEFAULT NULL, build_year smallint DEFAULT NULL, avg_unit_price int unsigned DEFAULT NULL COMMENT 小区均价分/平米, lng decimal(10,6) DEFAULT NULL, lat decimal(10,6) DEFAULT NULL, PRIMARY KEY (id), KEY idx_district (district_code), KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT小区表;逻辑说明avg_unit_price冗余在小区表列表页展示「小区均价」时不用实时聚合房源表聚合在房源上架下架时异步更新。经纬度用 decimal 存精度 6 位够到米级别用 floatfloat 在距离计算时会有精度漂移。参数说明build_year用 smallint年份不会超过 32767lng/lat的 decimal(10,6) 能表示到小数点后六位覆盖全球范围。如果要做「附近房源」这两个字段配合空间索引或者简单的经纬度范围查询都行数据量不大时不必上 PostGIS。2.3 房源状态流转与软删除房源状态不是简单的在售/已售中间有预定、锁盘、下架。我一般用状态机约束流转在售 → 预定 → 已售在售 → 下架预定 → 在售取消预定。状态变更走单独接口不直接 update 字段避免并发下状态错乱。软删除用status4表示下架物理删除只留给测试数据清理。# 状态流转校验伪代码但可直接改成业务代码 ALLOWED_TRANSITIONS { 1: [2, 4], # 在售 - 预定 / 下架 2: [1, 3], # 预定 - 在售 / 已售 3: [], # 已售终态 4: [1], # 下架 - 重新上架 } def change_status(house_id, from_status, to_status): if to_status not in ALLOWED_TRANSITIONS.get(from_status, []): raise ValueError(f非法流转: {from_status} - {to_status}) # 带 from_status 条件更新防止并发覆盖 affected db.execute( UPDATE house SET status%s WHERE id%s AND status%s, (to_status, house_id, from_status) ) if affected 0: raise RuntimeError(状态已被其他操作修改请刷新重试)逻辑说明ALLOWED_TRANSITIONS把合法流转写死任何绕过它的状态修改都是 bug 来源。更新时带AND status%s是乐观锁思路并发下只有一个请求能成功另一个拿到 0 行受影响提示重试。参数说明from_status必须由调用方传入当前状态不能查一次再更新否则中间有窗口期。3. 列表筛选与搜索让用户三秒内找到房房源列表页是流量入口筛选条件多、组合爆炸性能全压在这一层。我的做法是能走索引的走索引走不了的走缓存实在复杂的走异步预计算。下面按筛选维度拆。3.1 多条件组合筛选的 SQL 写法最常见的组合是「城市 区域 价格区间 户型 排序」。这种查询用普通二级索引就能扛关键是索引顺序要对。我一般建(city_code, district_code, status, total_price)这样的联合索引让等值条件在前、范围条件在后。-- 区域 价格区间 在售按单价排序 SELECT id, title, community_id, layout, area, total_price, unit_price, tags FROM house WHERE city_code 310100 AND district_code 310104 AND status 1 AND total_price BETWEEN 300000000 AND 800000000 ORDER BY unit_price ASC LIMIT 20 OFFSET 0;逻辑说明city_code、district_code、status三个等值条件命中联合索引前缀total_price范围条件在索引里做范围扫描ORDER BY unit_price如果不在索引里会触发 filesort数据量大时明显。如果排序字段固定是单价可以把unit_price也加进联合索引但索引会变宽写入变慢需要权衡。参数说明BETWEEN的边界是闭区间价格单位是分300000000 分即 300 万。LIMIT 20 OFFSET 0是分页深分页时 OFFSET 大了会慢后面讲优化。3.2 标签筛选与 JSON 字段查询标签筛选是房产系统的特色用户会勾「近地铁」「满五唯一」「精装修」。这些标签存在tagsJSON 字段里查询用JSON_CONTAINS。-- 筛选同时带近地铁和精装修的房源 SELECT id, title, total_price, tags FROM house WHERE city_code 310100 AND status 1 AND JSON_CONTAINS(tags, near_subway) AND JSON_CONTAINS(tags, fine_decoration) LIMIT 20;逻辑说明JSON_CONTAINS(tags, near_subway)判断 JSON 数组里是否包含指定字符串注意第二个参数是带双引号的 JSON 字符串字面量。多个标签用多个JSON_CONTAINS叠加是 AND 关系。这种查询走不了普通索引数据量上万后要配合缓存或倒排。参数说明标签值用英文枚举而不是中文避免字符集和转义问题。如果标签筛选是高频操作建议单独建一张house_tag关联表用(tag_code, house_id)建索引查询时 join比 JSON 扫描快。3.3 分页性能与缓存策略列表页第一页永远最快翻到第五十页就慢因为LIMIT 20 OFFSET 1000要扫描前 1020 行再丢弃。优化手段是游标分页记住上一页最后一条的排序值下一页从它之后取。-- 游标分页上一页最后一条 unit_price5200000, id8821 SELECT id, title, unit_price FROM house WHERE city_code 310100 AND status 1 AND (unit_price 5200000 OR (unit_price 5200000 AND id 8821)) ORDER BY unit_price ASC, id ASC LIMIT 20;逻辑说明游标分页用(排序字段, 主键)组合做「大于」条件避免 OFFSET 扫描。ORDER BY必须和游标条件字段一致且加主键做 tie-breaker否则相同单价的记录会漏或重。参数说明游标值由前端从上一页响应里带回来服务端不存状态天然适合分布式。缓存上我一般把「城市 区域 价格区间 户型」这种高频组合的首页结果缓存 60 秒用 Redis 存 JSONkey 用筛选条件的哈希。房源上架下架时按区域失效缓存不做全量刷新。4. 从浏览到留资转化链路怎么接房源详情页的留资表单是转化核心但很多系统把表单做成一个孤立的 POST用户提交完就断了。我一般把留资拆成「曝光 → 点击 → 填表 → 提交 → 分配」五步每步埋点方便后面看漏斗。4.1 留资表单的字段与校验表单字段不宜多姓名、手机号、意向程度三样起步。手机号校验用正则但别太严否则会拦掉一些号段。意向程度用枚举看房、咨询、比价。// 前端留资表单校验简洁版 const PHONE_RE /^1[3-9]\d{9}$/; function validateLead(form) { const errors []; if (!form.name || form.name.trim().length 2) { errors.push(姓名至少两个字); } if (!PHONE_RE.test(form.phone)) { errors.push(手机号格式不对); } if (![1, 2, 3].includes(form.intent)) { errors.push(请选择意向程度); } return errors; }逻辑说明PHONE_RE只校验 1 开头、第二位 3-9、共 11 位不校验具体号段归属因为号段会变。intent用数字枚举前端传 1/2/3后端映射成中文存库。参数说明姓名长度限制 2 到 20 字手机号去空格后再校验避免用户复制粘贴带空格。4.2 留资去重与防刷同一个手机号短时间重复提交要合并不能每次都插一条。我一般用(phone, house_id)做唯一约束重复提交时更新updated_at和意向程度不新增记录。防刷用 Redis 做频率限制同一 IP 一分钟最多五次。# 留资写入带去重和限流 def submit_lead(phone, house_id, name, intent, ip): # 限流同一 IP 一分钟最多 5 次 key flead_rate:{ip} count redis.incr(key) if count 1: redis.expire(key, 60) if count 5: raise RuntimeError(提交太频繁请稍后再试) # 去重同一手机号同一房源只保留一条 existing db.query_one( SELECT id FROM lead WHERE phone%s AND house_id%s, (phone, house_id) ) if existing: db.execute( UPDATE lead SET intent%s, updated_atNOW() WHERE id%s, (intent, existing[id]) ) return existing[id] return db.insert( INSERT INTO lead (phone, house_id, name, intent, ip) VALUES (%s,%s,%s,%s,%s), (phone, house_id, name, intent, ip) )逻辑说明限流用 Redis 的INCREXPIRE第一次自增时设过期时间简单可靠。去重先查后写并发下可能双插所以(phone, house_id)上要有唯一索引兜底插入冲突时捕获异常再走更新。参数说明ip从请求头取注意代理场景下取真实 IP 的字段因部署环境而异这里不展开。4.3 留资分配与状态跟踪留资进来后要分配给经纪人分配规则可以是轮询、按区域、按意向程度。分配后留资状态从「待分配」变「已分配」经纪人跟进后变「已联系」「已带看」「已成交」。状态跟踪用一张lead_follow表记录每次跟进主表只存当前状态。CREATE TABLE lead_follow ( id bigint unsigned NOT NULL AUTO_INCREMENT, lead_id bigint unsigned NOT NULL, agent_id int unsigned NOT NULL, action tinyint unsigned NOT NULL COMMENT 1联系 2带看 3成交 4无效, remark varchar(255) DEFAULT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_lead (lead_id), KEY idx_agent_time (agent_id,created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT留资跟进记录;逻辑说明跟进记录只追加不修改主表的当前状态由最新一条跟进记录决定或者冗余在主表里异步更新。idx_agent_time支持「某经纪人某时间段的跟进量」统计。参数说明action用 tinyint 枚举remark限 255 字够用且不撑大表。5. 避坑与排查那些让我加班到凌晨的细节这一章全是血泪经验每条都是线上真实翻过车的。现象列表页价格筛选结果不对300 万的房出现在 200 万区间里。原因价格字段用 float 存比较时精度漂移。解决改成 int 存分所有价格计算在分级别做展示时再除 100。迁移时用UPDATE house SET total_price ROUND(total_price * 100)批量转。现象房源详情页偶尔显示「已售」但列表页还在售。原因列表页走了缓存状态变更后没失效缓存。解决状态变更接口里按city_code district_code失效相关缓存 key或者给缓存加短过期时间兜底。我一般两者都做缓存 60 秒过期加主动失效。现象标签筛选越加越慢勾三个标签后查询要两秒。原因多个JSON_CONTAINS叠加每个都要全表扫描 JSON 字段。解决高频标签建关联表用(tag_code, house_id)索引查询改成JOIN house_tag加GROUP BY或IN子查询。数据量小的时候可以先加缓存顶一顶。现象留资表单提交后用户收到「系统错误」但数据库里其实插入了。原因插入成功后发短信或推送失败异常抛到前端。解决留资写入和通知解耦写入成功即返回通知走异步队列失败重试。别让非核心链路拖垮主流程。现象深分页翻到后面越来越慢最后超时。原因LIMIT OFFSET大偏移量扫描。解决改游标分页前端传上一页最后一条的排序值。如果前端改造成本高至少限制最大页数比如只允许翻到第 50 页后面引导用户加筛选条件。6. 进阶把筛选性能再压一档的几个手段前面讲的都是单机 MySQL 能扛住的量级房源到几十万、日活到几万时得再往上走一层。我一般按这个顺序加先加 Redis 缓存高频筛选组合再上 Elasticsearch 做多条件搜索最后考虑预计算宽表。Redis 缓存这块key 的设计很关键。我一般用house:list:{city}:{district}:{price_min}:{price_max}:{layout}:{page}这种结构但筛选条件组合太多会导致 key 爆炸。折中做法是只缓存「无筛选」「仅区域」「区域价格」这三类高频组合其他组合直接查库。缓存值存房源 ID 列表详情再批量取避免缓存大对象。Elasticsearch 适合标签多、排序维度多的场景。把房源同步到 EStags 存成 keyword 数组筛选用terms查询排序用sort分页用search_after。同步用 Canal 或者应用层双写我倾向应用层双写加定时对账因为 Canal 的运维成本不低。ES 的坑在于字段映射total_price要存成 integer 而不是 text否则范围查询会走错。预计算宽表是终极手段把「区域 户型 价格段」的房源数量、均价提前算好存一张统计表列表页只查统计表拿聚合数字具体房源再按 ID 取。这张表用定时任务每十分钟刷一次牺牲一点实时性换查询速度。适合首页的「区域行情」模块不适合精确筛选。验证性能有没有提升别靠感觉。我习惯用EXPLAIN看执行计划重点看type是不是ref或rangerows扫描行数有没有降下来Extra里有没有Using filesort或Using temporary。压测用sysbench或者简单的ab模拟 100 并发跑列表接口看 P99 延迟。我自己的经验是联合索引顺序调对列表接口能从 800ms 降到 80ms这个收益比换框架大得多。最后说个习惯每次加筛选条件前先想清楚这个条件走不走索引走不了的话数据量到多少会崩崩了用什么兜底。想不清楚就先别加或者加个开关上线后看慢查询日志再决定。房产系统的复杂度全在查询上把这块吃透后面加功能都是顺水推舟。希望帮到你。本文还有配套的精品资源点击获取