
做返利系統這行最怕的不是業務邏輯復雜而是數據庫在你毫無防備的時候突然塌掉。去年雙十一凌晨我們平臺的訂單同步服務還在批量拉取聯盟訂單主庫 CPU 直接沖到 97%所有返利狀態查詢全部卡在 InnoDB 的行鎖上用戶端一片待結算的紅色告警運營群直接炸了。那次之后我花了整整兩個月把數據庫優化策略徹底重做了一遍讀寫分離 分庫分表。今天這篇就是那次落地的完整復盤適合正在做返利、分銷、CPS 這類讀多寫少但數據膨脹飛快的系統的后端工程師參考。1. 返利系統的數據庫畫像讀多寫少不代表壓力小很多人在聊返利系統時都會下意識說一句這不就是讀多寫少嘛加幾個從庫不就完了。實際接手以后你會發現問題遠沒有這么簡單。返利系統確實以讀為主但它的寫入模式非常特殊不是均勻的、用戶觸發的小寫入而是定時拉單的批量寫、月末結算的批量更新、提現打款的狀態流轉。這些寫入一旦趕上用戶查詢高峰主庫的鎖競爭和復制延遲會同時爆發系統表現不是慢而是完全癱瘓。1.1 先看清楚返利業務的四條核心鏈路我習慣把返利系統的數據流拆成四條鏈路來理解因為每條鏈路的壓力特征完全不一樣用戶瀏覽鏈路用戶打開 App 查返利比例、搜商品、看精選榜單、查訂單列表、查返利流水。這些請求幾乎全是讀QPS 最高但 SQL 簡單絕大多數是帶 userId 或商品 id 的主鍵/二級索引查詢。訂單同步鏈路平臺定時去淘寶聯盟、京東聯盟等渠道拉取用戶的下單、付款、確認收貨狀態。每輪同步可能一次性拉回幾十萬條狀態變更落到庫里是大量的 UPDATE 和 INSERT這是典型的周期性寫洪峰。結算鏈路每天晚上跑批量任務把已過售后期、已經結算的訂單標記為可提現給用戶累計傭金余額寫返利流水。這一輪操作會更新大量訂單行還會更新用戶賬戶余額。提現鏈路用戶申請提現扣減余額、生成提現單然后等待打款回調。讀少、寫多但要求強一致絕對不能出現余額被扣了提現單卻丟了這種事故?;氐介_頭說的讀多寫少這里的寫少指的是用戶側寫入少但系統內部的批量寫入一點都不少。問題就在這讀請求天然適合水平擴展而批量寫入會制造主從延遲、鎖等待、慢查詢這些才是返利系統數據庫真正的殺手。1.2 數據庫不是被并發壓垮的是被三種情況拖垮的我復盤去年雙十一事故時把慢查詢日志和 InnoDB 狀態翻了個底朝天最后總結出三根壓垮主庫的稻草這三根稻草在絕大多數返利系統里都存在第一是單行熱點。平臺里總有那么幾個大團長、大淘客他們帶動的訂單量能占到全站百分之十幾。所有運營報表、訂單詳情都集中在同一批 userId 的數據上單個用戶的數據頁被高并發訪問行鎖競爭和 buffer pool 的 latch 爭搶非常嚴重。這種熱點和普通高并發不一樣加從庫解決不了因為請求永遠打在同一個數據頁上。第二是批量更新拖出長事務。聯盟訂單同步任務為了保證一致性經常在一個事務里更新幾萬條訂單狀態。這個事務一旦和用戶查詢撞上undo log 膨脹、鎖等待鏈變長、從庫回放跟不上主從延遲從毫秒級直接拉到幾十秒。用戶查到的訂單狀態和真實狀態嚴重不一致客服咨詢量暴增。第三是單表數據膨脹后的慢查詢。返利系統的訂單明細表、返利流水表是第一年最容易膨脹的表。一百萬訂單的時候userId 索引非常聽話到一千萬的時候索引樹的層級上來了歷史數據一多范圍查詢和排序開始變慢過了三千萬連簡單的 count、分頁都能把 CPU 打滿。三條鏈路里的讀流量可能再大也不會讓 MySQL 立刻崩潰因為 InnoDB 的讀擴展性其實挺強真正讓系統崩掉的是上面這三類問題。讀懂這張壓力畫像,再去做讀寫分離和分庫分表才不會方向跑偏。1.3 什么時候才值得上讀寫分離和分庫分表我見過不少團隊在業務剛起步、單庫跑得正歡時就忙著搞分庫分表結果引入了分布式事務和跨分片查詢一堆復雜度得不償失。結合返利系統的實際數據特征我建議至少滿足下面兩三條再動手主庫 CPU 長期在 60% 以上且慢查詢日志里大量是 SELECT。讀 QPS 與寫 TPS 比例明顯超過 10:1單純靠加從庫已經無法緩解主庫鎖競爭。訂單明細表或返利流水表超過 1000 萬行并且還在以每月百萬級速度增長。大促期間需要支撐平時 5 到 10 倍的峰值流量而運維手里沒有足夠的擴容手段。批量同步訂單和結算任務已經開始擠占核心業務查詢的數據庫資源連寫后立即讀這種基本需求都開始超時。如果只是偶爾一次大促扛不住我反倒建議先做緩存和 SQL 優化把熱點商品、返利比例、訂單列表都緩存起來說不定能多撐一年。但返利系統的數據特性注定了這條路走不長訂單和流水是用戶核心資產不能隨便淘汰緩存數據規模過了千萬就必須考慮讀寫分離再過了億級分庫分表就不可避免。關鍵是要在業務還扛得住的時候提前把方案想清楚。2. 讀寫分離落地MariaDB MaxScale 與應用層路由的實際選擇讀寫分離是整個優化方案里見效最快的一步做法也相對成熟一個主庫負責寫一個或多個從庫負責讀讀流量平均分發到從庫上主庫的壓力立刻降下來。但在實際落地時有兩個核心問題必須回答讀流量怎么路由主從延遲怎么兜底2.1 兩種路由方案的取舍返利系統里常見的讀寫分離路由方案有兩種一種是引入代理層如 MariaDB MaxScale另一種是在應用層用數據源路由框架。這兩種我都實際用過各有各的適用場景。代理層方案的代表是 MariaDB MaxScale。它部署在應用和數據庫之間對業務代碼完全透明應用連上 MaxScale 的端口就行它會自己解析 SQL 決定走主庫還是從庫。優點是 DBA 可以統一管控、加從庫不需要改代碼、還有自動故障切換能力缺點是所有數據庫流量多一跳網絡代理本身會成為新的單點而且它只能按 SQL 類型粗粒度分流對于一些需要寫后讀強一致的業務場景還是要靠規則來強制走主庫。應用層方案則是把路由規則寫在工程里。比如用 Spring 的 AbstractRoutingDataSource 配合自定義注解 Master、Slave在 Service 方法上聲明走哪個數據源。好處是路由邏輯完全可控可以在代碼里精細處理事務和延遲問題不需要額外維護代理組件壞處是侵入性強團隊必須嚴格遵守規范一旦有人忘了標注或者新同學不懂約定就容易把讀流量打到主庫上。我用一張表把兩邊的關鍵差異列出來方便你結合自己的團隊情況選對比維度MaxScale 代理層應用層數據源路由業務代碼侵入無侵入連接串改一下即可需要加注解、切數據源邏輯路由粒度按 SQL 關鍵詞粗粒度分流可以精細到方法級別主從切換自帶監控和自動切換需要自研或依賴中間件運維成本需要單獨運維代理機器無需額外組件部署簡單強一致定制靠 hints 或規則不夠靈活代碼里好控制適用團隊有專職 DBA庫表較多后端團隊自己管理數據庫返利系統這種業務我最終是兩套結合的核心交易鏈路走應用層路由因為要在代碼里精細控制寫后讀的強制主庫邏輯報表查詢、運營后臺這類低危流量走 MaxScale讓運維統一管控從庫和故障切換。2.2 MaxScale 讀寫分離代理的配置要點如果你用的數據庫是 MariaDBMaxScale 基本是官方標配它和 MariaDB Server 的生態融合得非常好。這里我給出一個最簡可用的 maxscale.cnf 配置骨架實際部署時把賬號、IP、密碼替換掉即可[maxscale] threadsauto [server1] typeserver address10.0.0.11 port3306 protocolMariaDBBackend [server2] typeserver address10.0.0.12 port3306 protocolMariaDBBackend [server3] typeserver address10.0.0.13 port3306 protocolMariaDBBackend [MariaDB-Monitor] typemonitor modulemariadbmon serversserver1,server2,server3 usermaxscale_monitor password強密碼 monitor_interval2s auto_failovertrue auto_rejointrue [讀寫分離服務] typeservice routerreadwritesplit serversserver1,server2,server3 usermaxscale_route password強密碼 master_accept_readsfalse max_slave_connections255 [讀寫分離監聽] typelistener service讀寫分離服務 protocolMariaDBClient port4006這里有幾個細節特別容易踩坑我逐個說明。首先MaxScale 的監控賬號 maxscale_monitor 和路由賬號 maxscale_route 權限不一樣。監控賬號需要能訪問 mysql 庫、執行 SHOW SLAVE STATUS、查看 performance_schema 里的復制信息否則監控不到主從延遲和故障路由賬號則是后端業務連接用的普通賬號權限不要給太大。這兩個賬號我見過很多團隊混用最后排查問題時監控日志一直報權限錯誤主從切換根本觸發不了。其次master_accept_reads 這個參數建議設成 false。它決定主庫是否接收讀流量寫入請求已經是主庫的單線程處理如果再把讀流量壓過去主庫的 IO 和 CPU 壓力下不來讀寫分離就失去了意義。從庫不夠了可以加從庫不要讓主庫讀。第三readwritesplit 會根據 SQL 類型自動分流INSERT、UPDATE、DELETE、DDL 和事務內的所有 SQL 都走主庫SELECT 走從庫。但它有個隱藏行為事務一旦開始事務內的所有語句都會被固定到主庫上這是為了保證事務一致性合理但會減少從庫的使用率。所以應用層盡量把只讀查詢放到事務外面別把簡單查詢包在一個大事務里。2.3 應用層注解路由的實現方式如果不想引入代理層應用層路由用 Spring 生態實現非常簡單。核心思路是用 AbstractRoutingDataSource 在運行時動態決定當前線程用哪個數據源再用一個注解在方法上聲明。下面是一個精簡示例。先定義一個線程級的數據源上下文public class DynamicDataSourceContextHolder { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void set(String key) { CONTEXT.set(key); } public static String get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }然后自定義注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DS { String value() default master; }切面在方法執行前把數據源名設置進 ThreadLocalAspect Component public class DataSourceAspect { Before(annotation(ds)) public void before(JoinPoint point, DS ds) { DynamicDataSourceContextHolder.set(ds.value()); } After(annotation(ds)) public void after(JoinPoint point, DS ds) { DynamicDataSourceContextHolder.clear(); } }最后在配置類里注冊動態數據源Configuration public class DataSourceConfig { Bean public DataSource dynamicDataSource() { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(master, masterDataSource()); targetDataSources.put(slave, slaveDataSource()); // 可以配置多個從庫按權重輪詢或隨機 DynamicRoutingDataSource routingDataSource new DynamicRoutingDataSource(); routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.setDefaultTargetDataSource(masterDataSource()); return routingDataSource; } }這樣在業務方法上寫 DS(slave) 就自動走從庫不寫就走默認主庫規則非常簡單。但我要特別提醒一個 Spring 事務的坑如果一個方法上有 Transactional事務會在進入方法時就綁定數據源連接這之后你再在內部切數據源是無效的連接已經和事務綁死在主庫上了。所以我的經驗是所有需要事務的方法一律強制走主庫只讀查詢方法一律不要加 Transactional。2.4 主從延遲與寫后讀強制走主庫讀寫分離上線后最大的敵人從主庫 CPU 變成了主從延遲。MySQL 的主從復制默認是異步的從庫回放主庫的 binlog 需要時間正常情況延遲在毫秒級但遇到大事務、DDL、從庫磁盤 IO 慢延遲就會被拉到秒級甚至分鐘級。返利系統里最容易暴露延遲的就是寫后讀場景。用戶剛提交提現申請你后端寫完了主庫頁面緊接著要查最新余額如果這個查詢走了從庫讀到的還是老余額用戶就會覺得提現沒成功反復點提交產生一堆重復單。我處理這類問題的辦法有三層第一層在代碼層面強制寫后讀走主庫。凡是同一個用戶在同一會話內剛發生寫過操作又立即讀的場景讀請求直接標記為主庫執行。最粗暴但有效的做法是因為現在讀多寫少比例懸殊這類核心讀走主庫的成本完全可接受關鍵業務不會錯。第二層使用短時間本地緩存路由表。比如用戶提交提現后 3 秒內這個 userId 的查詢一律路由到主庫。實現就是在 Redis 里設置一個帶過期時間的 key查詢時看到這個 key 就切主庫。這個方案可以覆蓋絕大多數用戶剛操作完立刻刷新的場景。第三層用復制心跳監控從庫延遲。Percona Toolkit 的 pt-heartbeat 工具會在主庫周期性寫入心跳時間從庫通過對比當前時間來算出精確延遲。我把告警閾值設在 3 秒任何一個從庫延遲超過閾值就把它的讀流量摘掉等追平后再恢復。這樣不僅避免用戶讀到臟數據也保護了從庫不被持續拖垮。3. 分庫分表的具體拆分訂單、流水、提現記錄讀寫分離解決的是并發讀壓力但數據庫數據量一旦到了千萬、億級單表本身的性能瓶頸就出來了。返利系統的訂單明細、返利流水膨脹速度極快是我做分庫分表的首批目標。3.1 分片鍵鎖定 userId 的理由分庫分表第一件事就是選分片鍵這個選擇直接決定未來所有查詢的形態。返利系統里我幾乎沒有猶豫就選了 userId原因是這個業務的訪問模式太清晰了用戶查返利比例是按 userId 關聯的查訂單列表是按 userId 的查返利流水也是按 userId 的甚至訂單同步回來確認歸屬時也是按 userId 去更新用戶的返利記錄。以用戶維度分片天然把所有熱點數據放在同一個分片上用戶訂單、流水、余額可以做成局部性很強的一組數據。對比一下用 orderId 分片的后果用戶查我的訂單列表時你不知道他的訂單落在哪個分片上只能向所有分片發起查詢然后聚合排序這就是典型的跨分片查詢災難。更麻煩的是結算任務按訂單更新狀態時如果訂單和用戶余額不在同一個分片就需要分布式事務復雜度直接翻倍。所以選擇 userId 作為分片鍵本質上是把用戶的數據內聚在同一個分片內讓結算、提現這類資金相關操作可以在單分片內用本地事務完成。返利系統的業務特性決定了這個選擇幾乎是一本萬利。3.2 分片算法、全局主鍵與擴容分片算法我建議先做簡單的取模再用一致性哈希過渡到分段映射不要一上來就搞很復雜的算法。假設我們規劃 16 個物理分片用戶 id 是 10086那它落的分片就是 10086 % 16 6。這個算法足夠簡單路由時計算開銷幾乎為零配合分片配置表就能解決絕大多數問題。但取模有一個硬傷擴容時幾乎全部數據都要遷移。16 個分片擴到 32 個原來分片 0 里的數據按新規則計算可能要去分片 0、16、20、31 等等數據基本全動。所以我在設計時提前做了一步把 userId 先通過一致性哈希映射到一個邏輯分片再把邏輯分片映射到物理分片。這樣擴物理庫時只遷移一部分邏輯分片的數據。說說我們當時的擴容操作流程這套流程后來也成了團隊的標準動作在配置中心發布新的分片映射規則路由層先開啟新老雙讀讀流量同時查詢新舊分片以新分片為準老分片數據只做校驗。啟動離線遷移任務按邏輯分片為單位把老分片的數據按新規則寫入對應新分片過程中記錄遷移進度和校驗位點。每個邏輯分片遷移完成后對比新老庫的行數、金額 sum、MD5 校驗值全部一致才算通過。全量遷移完成后把寫流量切到新規則保留老分片只讀狀態觀察一段時間。觀察 3 到 7 天無異常下線老分片。還有全局主鍵也必須提前設計。多分片下不能用數據庫自增 id 當主鍵否則多個分片會生成重復 id訂單號、流水號又會拿這個 id 去關聯別的地方撞車就亂套。我們用的是雪花算法生成的 64 位 Long 型 id特點是趨勢遞增、全局唯一非常適合返利系統的訂單表、流水表、提現表。生成時注意把機器 id 和數據中心 id 配置好避免部署多實例后重復。3.3 訂單與返利流水的表結構規劃分庫分表不是只能分庫實際落地時我把分庫 分表 冷熱歸檔三層疊加在一起。以訂單明細表為例表名規劃是 cashback_order_{0..15}十六張表按 userId 取模分布在這十六張表內部再按訂單創建時間的月份做分區。元數據上再用一張配置表記錄當前活躍分片、歷史分片狀態。返利流水表的設計也類似rebate_flow_{0..15}按 userId 分片同時按流水產生月份分表。這樣做的原因是流水表是所有表里增長最無情的用戶每筆訂單的狀態變化都要寫流水一條訂單從下單到結算可能產生 3 到 5 條流水數據量是訂單表的三倍。提現記錄表反而簡單按 userId 分片即可提現頻率遠低于訂單不必再做月份分表。但提現表有個特殊要求必須給 (userId, withdraw_no) 建唯一索引。返利系統的提現模塊經常收到重復回調或前端重復提交唯一索引是防重復最底層的屏障。這里給出我們線上表規劃的核心參考表名分片規則保留策略關鍵索引說明cashback_order_{0..15}userId % 16熱表保留 90 天超過歸檔uk(order_id)、idx(user_id, create_time)訂單狀態變化頻繁必須按用戶和時間雙索引rebate_flow_{0..15}userId % 16保留 2 年idx(user_id, create_time)、idx(order_id)流水量大按用戶與時間查是常態withdraw_record_{0..15}userId % 16永久uk(user_id, withdraw_no)、idx(user_id, status)資金表嚴格冪等防止重復扣款user_account_{0..15}userId % 16永久pk(user_id)用戶傭金余額資金類嚴禁全表掃描3.4 繞開跨分片查詢的三條路徑分片鍵選了 userId日常用戶維度的查詢都舒服了但總有一些查詢天然不帶 userId比如運營后臺要查全局訂單趨勢、財務要匯總當天全站返利金額。這種跨分片查詢如果直接在業務庫上廣播執行十六張表、上億行數據一個聚合 SQL 就能拖垮全部分片。我的處理方式是盡量把跨分片查詢從 OLTP 鏈路里剝離出去。運營報表、財務匯總全部走獨立的數據通道每天定時從各分片的從庫同步一份匯總數據到分析庫或者灌入 ElasticSearch / ClickHouse報表查詢只打這套分析系統。第二條路徑是分片并行任務。比如訂單同步任務需要掃描全局訂單那就按分片拆成 16 個 task每個 task 只處理自己分片的數據并行跑。這個方案對批量任務特別有效因為每個分片的數據互相獨立完全可以并行處理整體吞吐是單線程的 16 倍。第三條路徑是禁止無分片鍵的深分頁和 join。用戶訂單列表的分頁一定要帶 userId 條件讓 SQL 落在單個分片內執行跨分片查詢如果用 limit 100000, 20 這種寫法每個分片都要掃描十萬行再合并排序性能必然爆炸。我統一改成游標分頁用上一頁最后一條記錄的 create_time id作為下一頁的查詢起點實測 TP99 能降一個數量級。4. 一致性優先從主從延遲到資金事務的邊界設計讀分庫分表改造最容易出事的不是性能而是數據一致性。返利系統里有真金白銀的余額和提現一致性要求比一般業務高很多。我在這個項目里最大的體會是不要把問題升級到分布式事務層面去解決而是通過合理的數據分布和業務設計讓大部分分布式問題變成單庫本地問題。4.1 讀寫分離下的一致性讀策略讀寫分離上線后查詢讀到舊數據的問題幾乎天天有人反饋。除了前面說的延遲監控還有一個細節容易被忽略批量任務自己產生的數據如果批量任務內部有寫完立即查的邏輯也常常打到從庫導致查到舊值。比如訂單同步任務剛把一批訂單狀態改成已確認緊接著去查這批訂單算返利結果查到幾天前的狀態返利金額少算或漏算。我的統一策略是任何寫操作所在的方法內后續的讀操作必須走主庫只有獨立于寫路徑之外、對時間不敏感的查詢才允許走從庫。用個直白的話說就是寫完就讀的別貪從庫那點性能從庫只服務那些晚幾秒看到也無所謂的頁面。在代碼落地時我給所有 Service 方法分了兩類一類是命令方法有寫操作方法內全部用默認主庫數據源不切從庫另一類是查詢方法才允許使用 DS(slave)??窟@個簡單約定團隊里的寫后讀臟讀問題基本絕跡了。4.2 結算與提現如何用本地事務解決分布式問題前面選 userId 分片的好處在資金相關事務上體現得最徹底??匆粋€具體的結算場景訂單確認收貨后系統要做三件事更新訂單的返利狀態為可提現、給用戶賬戶余額累加返利金額、寫入一條返利流水。因為訂單表和賬戶余額表、流水表都按 userId 分片這三張表在同一個物理分片里那么一個本地事務就能搞定BEGIN; UPDATE cashback_order_6 SET rebate_status settled WHERE order_id ? AND user_id 10086; UPDATE user_account_6 SET available_amount available_amount ? WHERE user_id 10086; INSERT INTO rebate_flow_6 (flow_id, user_id, order_id, amount, status) VALUES (?, 10086, ?, ?, settled); COMMIT;這個事務只在分片 6 的數據庫上執行沒有跨庫不需要兩階段提交。只要三行數據都在同一分片MySQL 本地事務就保證了原子性要么全部成功要么全部回滾。這個設計的價值在實際運維中會體現得非常充分我見過團隊把訂單庫和賬戶庫拆成獨立的微服務庫然后去搞柔性事務、消息補償光排查返利加了但流水沒寫的故障就花了幾周。數據分布設計得當這些復雜度根本不應該存在。提現業務的邏輯類似扣減用戶余額、創建提現單、更新提現單狀態全部在 userId 分片內本地事務完成。提現單狀態機我建議定義成待處理、打款中、成功、失敗、已退回。每一筆提現從創建到終態狀態流轉要記錄操作人和時間方便對賬。4.3 對賬、冪等與失敗補償即使本地事務保證了單分片內的原子性整個系統的最終一致性還需要對賬來守護。返利系統每天凌晨必須跑三類對賬一是分片內對賬每個分片獨立執行統計本分片的訂單數、返利總金額、用戶余額總和、流水筆數然后匯總到全局。任何一個分片數據異常都能快速定位到具體分片而不是全表撒網排查。二是聯盟側對賬把系統內的訂單金額、返利金額和淘寶聯盟、京東聯盟后臺的匯總數據做對比。聯盟接口偶爾會丟回調、延遲回調這種對賬能把漏掉的訂單撈回來是返利系統資金安全的重要防線。三是冪等兜底訂單同步任務天然會重復拉取同一筆訂單因為聯盟接口拉取窗口可能重疊。我的做法是在訂單表加唯一索引 uk(platform_order_id)INSERT 用 ON DUPLICATE KEY UPDATE 做冪等更新。提現打款回調也可能重復通過 withdraw_no 唯一索引兜底。至于失敗補償我把所有異步任務都接入了 MQ 重試 本地消息表。比如提現打款請求發出后如果支付回調一直不來定時任務會重新掃描狀態為打款中且超過 30 分鐘的提現單主動查詢支付平臺狀態。這套機制跑了一年多最壞情況下也能保證在 15 分鐘內追平異常單。5. 容量評估與壓測驗證這套方案到底扛住了多少流量做完讀寫分離和分庫分表之后我心里其實一直沒底因為架構升級誰都會說真正驗證它能不能抗住大促流量得靠數據說話。這里我講一下我們的容量評估方法和壓測結果給你一個可參考的量化過程。5.1 按業務量反推開分片數和從庫數以我們平臺為例子注冊用戶 500 萬日活 50 萬日常頁面 PV 2000 萬其中 80% 是返利比例查詢、訂單查詢這類讀請求。日均訂單同步量在 100 萬筆大促峰值能到平時 8 到 10 倍。先算讀容量。MySQL 單實例在硬件正常、SQL 有索引的情況下混合讀寫 QPS 大概能到 4000 到 6000。日常平均讀 QPS 約 2000 萬 PV / 86400 秒約等于 230但這是平均值高峰期至少放大 10 倍也就是 2300大促再放 10 倍就是 23000 的峰值讀 QPS。一顆主庫當然扛不住我規劃了 1 主 3 從主庫負責寫和核心讀3 個從庫分擔日常讀流量。大促前臨時擴容到 5 個從庫單個從庫峰值壓在 5000 QPS 左右比較安全。再算數據容量。日均 100 萬筆訂單一年就是 3.65 億如果不分表訂單表直接爆掉。按 16 個分片算每個分片一年約 2280 萬行看起來還行但如果連續跑 3 年單分片接近 7000 萬行仍然偏大。所以我在 16 分片的基礎上再加了 90 天冷熱歸檔超過 90 天的訂單導到歸檔庫業務庫里單分片只保留近三個月約 900 萬行壓力大大減輕。賬面數字算完方向就有了讀寫分離解決并發讀分庫分表解決數據膨脹冷熱歸檔解決歷史包袱三者配合而不是各自為戰。5.2 壓測怎么設計才貼近真實業務系統上線前必須壓測但壓測設計不合理會給你虛假的安全感。我壓測時沒有只壓一個簡單的 SELECT 1而是按線上實際請求比例構造了混合場景返利比例查詢 40%、訂單列表 30%、流水查詢 20%、訂單同步寫入 5%、結算更新 5%再疊加部分無索引或者范圍查詢模擬慢請求。壓測工具上基礎性能用 SysBench 測 MySQL 單機極限業務場景用 JMeter 模擬 HTTP 接口。重點關注四個指標整體 QPS/TPS、TP99 延遲、主從延遲水位、連接池占用率。我們壓出來的結果是這樣的單主庫混合讀寫 QPS 大約是 4500TP99 在 50 毫秒加了 MaxScale 和 3 個從庫之后整體讀 QPS 到了 13000 左右TP99 穩定在 80 毫秒以內主庫的 TPS 保持在 800 到 1000CPU 占用從之前的 90% 降到 30%。瓶頸反而轉移到了 MaxScale 的連接數和后端連接池配置上——連接數一旦超過閾值代理層開始排隊吞吐不升反降。這個發現告訴我們架構改造后還要同步調連接池參數不能只盯數據庫本身。5.3 大促前一周的備戰清單大促前我會帶著運維團隊把這些事情全過一遍差一項都不敢拍胸脯從庫提前擴容到位延遲監控閾值配置好告警能直達值班群。慢查詢日志全量打開提前一天預跑一遍大促核心 SQL收集執行計劃。批量任務錯峰聯盟訂單同步從每小時一次改成每 10 分鐘小批量拉取結算任務挪到凌晨 2 點到 5 點低峰期執行避免和流量高峰疊加。緩存預熱把熱門商品返利比例、熱門榜單提前加載到 Redis減少后端穿透到數據庫的讀請求。限流降級預案一旦主庫水位告警先對查詢量最大的幾個接口做限流返利比例查詢降級為讀取緩存中的近似值。主從切換演練大促前強制做一次從庫提升演練確保 MaxScale 的自動切換不是紙面功能。這套備戰清單后來成了標準操作流程今年大促我們最高單日訂單量到了 1100 萬筆數據庫層面沒有再出現過一次嚴重告警。6. 落地過程中踩過的坑和對應的處理方式寫了這么多方案最后分享幾個我們真實踩過、并且修復代價不小的坑。這些坑在文檔里不容易看到但對正在規劃同樣改造的你很有參考價值。6.1 復制鏈路與代理賬號的坑上線 MaxScale 后的第一個月就遇到過一次從庫延遲報警但查不出原因的情況。后來發現是主庫 binlog_format 設置成了 STATEMENT從庫回放大事務時同一批 SQL 在不同從庫上執行的時間差異很大導致延遲抖動。統一改成 ROW 格式之后問題解決。注意ROW 格式下 binlog 體積會變大很多需要盯著磁盤容量別讓 binlog 把磁盤塞滿這是另一個常見的坑。監控賬號的坑前面提過MaxScale 的 mariadbmon 模塊需要有 SHOW SLAVE STATUS 和讀 mysql 系統庫的權限。我見過有人用業務賬號當監控賬號結果主庫宕機時 MaxScale 根本沒感知到failover 全程沒觸發業務掛了二十分鐘。給監控賬號單獨授權、單獨密碼并定期用 SHOW REPLICA STATUS 驗證監控賬號能看到復制狀態。6.2 事務方法里切數據源路由失效的坑這個坑是應用層路由方案最經典的問題。有段時間我們的訂單列表接口偶爾會報事務已開始不能切換數據源的錯誤排查后確認是有個查詢方法被加了 Transactional(readOnly true)方法內部又調用了標記 DS(slave) 的 Mapper 方法。因為 Transactional 一進入就綁定主庫連接再切數據源完全無效所有查詢全部壓到了主庫上。處理辦法是雙管齊下一方面明確約定事務方法內部不允許切數據源代碼 review 時專門檢查另一方面配置里把只讀事務的 default 數據源設置為從庫這樣即使有人寫 Transactional(readOnly true) 也不會誤傷主庫。如果你用的是 ShardingSphere它的事務和讀寫分離規則也有類似問題記住一個原則事務邊界優先于數據源路由。6.3 分片后的分頁與跨分片統計上線分庫分表后運營要拉一份全站訂單明細直接 SELECT * FROM cashback_order LIMIT 1000000, 20這個 SQL 在十六個分片上各自執行了一遍每個分片都掃描了幾百萬行把數據庫 CPU 打到 80%。我拉上運營聊了需求本質他們要的只是一份導出文件不是在線查詢。于是改成定時生成導出任務按分片并行掃描每個分片只導當天增量最后合并文件報表需求改走數據通道在線查詢全部限制只能帶 userId??绶制y計也踩過類似的坑。財務要實時的全站返利總額最初前端直接調聚合接口十六個分片實時 sum響應時間 5 秒以上。最后改成每 5 分鐘在分析庫里預聚合一次總額接口只需查一行匯總記錄響應降到幾十毫秒。6.4 灰度遷移老數據的順序分庫分表改造最怕一次性全量切換出問題連回滾的機會都沒有。我們當時的順序是先在測試環境用影子庫驗證路由規則再在預發環境跑全量遷移演練最后在生產環境按邏輯分片灰度?;叶攘6瓤刂圃诿客碇贿w移 1 到 2 個邏輯分片每遷完一個分片就跑一遍對賬腳本確認新老庫數據完全一致第二天早上觀察業務無明顯異常再繼續下一批。整個過程花了接近兩周同事覺得太慢了但事實證明慢就是快期間確實發現過兩次遷移腳本對金額精度處理不一致的問題都被對賬攔截在了小范圍內沒有影響線上用戶。如果是趕在大促前十天一把梭遷移大概率會出大事。回看這次數據庫優化我自己最深的體會是讀寫分離和分庫分表不是目的而是為了讓返利業務的核心鏈路——查返利、同步訂單、結算、提現——在數據增長和流量洪峰下依然可維護、可預期。所有技術選型都圍繞一個原則做盡量把復雜問題收斂到單庫局部去解決實在收斂不了的用異步、對賬和冪等來兜底。最后留一個小技巧給你上線前一定要折騰一次真實的故障演練把主庫宕機、從庫延遲、分片遷移失敗各演一遍演練時出的洋相都是大促當天可能救你命的經驗。