← 回所有專案

後端技術負責人 ‧ 房仲業 · 2023

房仲平台核心系統重構

將大型房仲平台的主要流量頻道,從 .NET Framework/IIS 重構為 Kubernetes 上的 CQRS + Event Sourcing 架構:DMZ 以 API Gateway 作為唯一入口,內網拆出買屋、行情、社區三大資料中台,讀取全部走 Elasticsearch(含 GEO 地圖搜尋),MSSQL 異動透過排程與 RabbitMQ 同步進索引,並以 OpenTelemetry、Grafana、NeuVector 與 Argo CD 建立可觀測性與 GitOps 維運。

C#ASP.NET CoreCQRSEvent SourcingHangfireRabbitMQElasticsearchElasticsearch GEOMSSQLKubernetesAPI GatewayNeuVectorOpenTelemetryGrafanaArgo CDIaC
房仲平台核心系統重構

專案概述

主導公司主力房仲平台的主要流量頻道重構,包含買屋、成交行情、社區與快售等 頻道,將系統從 .NET Framework/IIS 遷移至 Kubernetes 上的 CQRS + Event Sourcing 架構。

重構前,每個頻道都是一個獨立的 IIS 站台,各自直接查詢 MSSQL,再把查詢結果整包 放進記憶體快取來撐住流量——每個站台光快取就吃掉約 10G 記憶體,資料更新要等 快取過期,部署時快取要重新暖機,一次部署就要 30 分鐘。搜尋類的查詢(多條件篩 選、地圖範圍)對關聯式資料庫本來就不友善,複雜一點的條件要 20 秒才回得來。

這次重構的方向是把「讀」和「寫」拆開:

  • 寫入端維持在 MSSQL,作為資料的唯一真實來源(Single Source of Truth)
  • 讀取端全部改由 Elasticsearch 提供,並依業務領域拆成買屋、行情、社區三 大資料中台,前端與集團內其他系統都透過中台取資料,不再直接碰資料庫
  • 中間用排程 + RabbitMQ 把 MSSQL 的異動同步進 Elasticsearch

系統架構

房仲平台架構圖:使用者經由 DMZ Kubernetes 上的 API Gateway 進入內網,內網 Kubernetes 上有前端網站與買屋、行情、社區三大中台,中台都查詢 Elasticsearch;MSSQL 原始資料透過 BCH 批次全量轉入、同步排程與 RabbitMQ 增量同步到 Elasticsearch;遙測資料送到 OpenTelemetry Collector 再由 Grafana 呈現,Argo CD 依 Git 上的設定同步 DMZ 與內網叢集

整體分成兩個網路區域、兩座 Kubernetes 叢集:

  • DMZ:只放 API Gateway,是整個平台唯一對外開放的入口。外部請求一律先 進 Gateway,再依路由規則轉進內網,內網的服務本身不對外暴露
  • 內網:前端網站與三大中台都跑在內網的 Kubernetes 上;Elasticsearch、 RabbitMQ、MSSQL 等資料服務也都只在內網可達

把對外入口收斂到一個點的好處是,所有安全控管、流量監控、路由調整都只需要在 Gateway 這一層處理;內網的中台可以專心做資料查詢,不用各自處理對外的防護。

三大資料中台

三個中台都是 ASP.NET Core Web API,背後都只查詢 Elasticsearch:

中台提供的資料主要查詢
買屋中台在售物件關鍵字、多條件篩選、自動補全、地圖搜尋、鄰近物件推薦
行情中台實價登錄 / 成交行情區域與社區行情、地圖搜尋
社區中台社區(建案)資訊社區基本資料、社區內物件與行情彙整

以「業務領域」而不是「頁面」來切中台,讓同一份資料只有一個出口:前端的買屋頁、 App、集團內的其他品牌網站要查在售物件,都是打同一個買屋中台,不會再出現各站台 各寫一套查詢、結果卻對不起來的情況。

地圖搜尋:Elasticsearch GEO

地圖搜尋畫面:在信義區地圖上依可視範圍顯示每個物件與成交案件的價格標籤

買屋與行情頻道都有地圖找房:使用者拖曳、縮放地圖時,畫面上要即時顯示這個 範圍內的物件與成交價格。這類「範圍內有哪些點」的查詢,正好是 Elasticsearch GEO 的強項。

每一筆物件 / 成交資料在建立索引時,就把經緯度存成 geo_point:

JSON
PUT /buy-house
{
  "mappings": {
    "properties": {
      "location":   { "type": "geo_point" },
      "price":      { "type": "scaled_float", "scaling_factor": 10 },
      "city":       { "type": "keyword" },
      "district":   { "type": "keyword" },
      "updateTime": { "type": "date" }
    }
  }
}

前端在地圖移動後,把目前可視範圍的左上角與右下角座標送給中台,中台組成 geo_bounding_box 查詢,再疊上使用者選的篩選條件(總價、房數、屋齡…):

JSON
GET /buy-house/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "geo_bounding_box": {
            "location": {
              "top_left":     { "lat": 25.0480, "lon": 121.5500 },
              "bottom_right": { "lat": 25.0300, "lon": 121.5800 }
            }
          }
        },
        { "range": { "price": { "lte": 3000 } } }
      ]
    }
  }
}

物件詳細頁的「附近物件推薦」則改用 geo_distance,以目前物件為圓心找出一定 半徑內的其他物件,再依距離排序。

幾個實作上的考量:

  • 條件都放在 filter:地圖範圍與篩選條件都是「符合 / 不符合」,不需要算相關 性分數,放在 filter 裡 Elasticsearch 可以快取結果,拖曳地圖時的重複查詢會快 很多
  • 只回傳畫面需要的欄位:地圖上的標籤只需要座標、價格與 ID,用 _source 過 濾掉其他欄位,點進去時才查詳細資料
  • 限制單次回傳數量:縮放到很大的範圍時物件數量會暴增,中台會限制單次筆數, 避免一次把幾萬筆點丟給前端畫

資料同步:從 MSSQL 到 Elasticsearch

資料同步流程圖:全量時由 BCH 批次把 MSSQL 整批轉入 Elasticsearch;增量時 DBA 更新資料會一併寫入異動欄位,同步排程定期比對異動欄位撈出變動資料,送進 RabbitMQ,再由同步消費者重組文件並更新索引

讀取全部改走 Elasticsearch 之後,最關鍵的問題就變成:MSSQL 的資料改了,索引 要怎麼跟上? 同步分成兩條路線:

① 全量:BCH 批次

原始資料由 BCH 批次從 MSSQL 整批讀出、轉成 Elasticsearch 的文件格式後寫入, 用在第一次建立索引,或是 mapping 調整後需要整份重建的時候。

② 增量:異動欄位 + 排程 + RabbitMQ

日常的資料更新不可能每次都整批重跑,所以和 DBA 約定:資料在更新時,一併更新 一個異動欄位。我寫的同步排程(Hangfire)定期比對這個欄位,找出上次同步之後 有變動的資料,把每一筆異動包成事件送進 RabbitMQ,再由同步消費者重組成 Elasticsearch 文件、更新對應的索引。

C#
// 示意:排程只負責「找出變動、發出事件」,不直接寫 ES
public async Task DetectChangesAsync()
{
    var checkpoint = await _checkpointStore.GetAsync("buy-house");
    var changed = await _repository.GetChangedSinceAsync(checkpoint);

    foreach (var item in changed)
    {
        await _publisher.PublishAsync(new HouseChangedEvent(item.Id, item.ChangeMark));
    }

    if (changed.Count > 0)
    {
        await _checkpointStore.SaveAsync("buy-house", changed.Max(x => x.ChangeMark));
    }
}

中間多放一層 RabbitMQ,而不是排程撈到資料就直接寫 Elasticsearch,原因是:

  • 偵測與寫入解耦:排程只負責「找出哪些資料變了」,寫索引的工作交給消費者。 某一段時間異動量暴增時,事件會先在佇列裡排隊,消費者照自己的速度處理,不會把 排程或 Elasticsearch 壓垮
  • 失敗可重試:單筆文件寫入失敗時,事件可以重新投遞或進入死信佇列,不會因為一 筆壞資料讓整批同步中斷
  • 一份異動、多個訂閱者:同一筆物件異動,買屋索引要更新、社區的物件彙整也可能 要更新,各自訂閱同一個事件即可,不需要在排程裡寫死所有下游

安全與可觀測性

NeuVector:DMZ 流量監控

DMZ 的 Kubernetes 上部署了 NeuVector,監控容器之間與進出叢集的網路流量。 API Gateway 是唯一對外的服務,也是最常被掃描、攻擊的地方;NeuVector 會依平常的 流量模式學習出正常的連線行為,出現異常的連線或流量時發出告警,讓問題在進到內網 之前就被看見。

OpenTelemetry + Grafana:全面可觀測性

DMZ 與內網的服務都接上 OpenTelemetry,Traces、Metrics、Logs 統一送到 OpenTelemetry Collector,由 Collector 集中處理後轉送到後端儲存,最後在 Grafana 上用儀表板呈現:

  • 一個請求從 API Gateway 進來、經過哪個中台、查了哪個索引、花了多少時間,都能從 同一條 Trace 串起來
  • 各中台的請求量、延遲、錯誤率,以及同步佇列的堆積狀況,都在 Grafana 儀表板上 一眼看得到
  • 應用程式只需要對接 Collector,之後要更換或新增後端儲存,只要改 Collector 的設 定,不必改程式

部署:Argo CD + IaC

兩座 Kubernetes 叢集都交給 Argo CD 管理,基礎設施與 Kubernetes 的部署設定 全部以程式碼描述、放在 Git 上:

  • 要部署或調整設定,就是發一個 PR;合併後 Argo CD 自動把叢集同步成 Git 上描述的 狀態
  • 叢集的實際狀態若被手動改動、和 Git 不一致,Argo CD 會標示出差異,避免「線上 跟設定檔對不起來」的情況
  • 每一次變更都有 Git 紀錄可以追溯,出問題時可以直接回退到上一個版本

遷移策略:新舊系統並行

重構不是一次切換。API Gateway 除了是對外入口,也是遷移期間的路由開關:新的 中台與頁面完成一個,就在 Gateway 上把對應的路由切到新系統,還沒完成的部分繼續導 回舊的 IIS 站台。整個遷移過程新舊系統並行運作,對使用者完全沒有中斷,某個 新功能有問題時也能立刻把路由切回舊系統。

成果

  • 移除每個 IIS 站台 10G 的記憶體快取,記憶體資源減少 超過 80%(10G → 800Mb)
  • 數據中台上線後,查詢回應速度提升 10 倍以上(20 秒 → 1.5 秒)
  • 容器化並移除記憶體快取後,後端部署效率提升 5 倍以上(30 分鐘 → 5 分鐘)
  • 前後端各別容器化後,前端部署效率提升 3 倍以上(15 分鐘 → 5 分鐘)
  • 買屋、行情、社區資料由三大中台統一提供,集團內其他系統可直接共用

使用技術

  • 後端:C#、ASP.NET Core Web API、CQRS、Event Sourcing、Hangfire
  • 資料:MSSQL、Elasticsearch(geo_point、geo_bounding_box、geo_distance)、BCH 批次
  • 訊息:RabbitMQ
  • 平台:Kubernetes(DMZ / 內網兩座叢集)、API Gateway
  • 安全:NeuVector
  • 可觀測性:OpenTelemetry、OpenTelemetry Collector、Grafana
  • 部署:Argo CD(GitOps)、IaC