分布式鎖的原理與高可用實踐)
1. 為什么“數(shù)據(jù)庫for update”在分布式鎖場景里既讓人上頭又讓人上癮“Java架構直通車”這個標題乍看像培訓廣告但真正跑過線上高并發(fā)系統(tǒng)的人都知道——它背后藏著一群在凌晨三點盯著數(shù)據(jù)庫死鎖日志、一邊灌咖啡一邊改SQL的開發(fā)者。我參與過三個不同行業(yè)的中臺系統(tǒng)重構從電商秒殺到金融賬戶扣減最后都繞不開一個樸素得近乎寒酸的方案用MySQL的SELECT ... FOR UPDATE實現(xiàn)分布式鎖。不是沒試過Redis、ZooKeeper、Etcd而是當QPS沖到8000、事務鏈路橫跨5個微服務、回滾代價動輒百萬級時我們發(fā)現(xiàn)最慢的鎖有時反而是最穩(wěn)的鎖。關鍵詞里雖然空著但標題本身已經(jīng)把核心要素釘死了Java語言棧、架構級決策不是工具調(diào)用是系統(tǒng)設計取舍、數(shù)據(jù)庫載體、FOR UPDATE核心機制、分布式鎖目標能力。這五個詞連起來不是教你怎么寫一行代碼而是在問當CAP理論撞上銀行級一致性要求你敢不敢把鎖的命脈交給事務日志很多人一聽到“數(shù)據(jù)庫做分布式鎖”就搖頭理由很充分性能差、單點瓶頸、死鎖風險高。這些都沒錯。但問題從來不是“能不能用”而是“在什么條件下它是唯一能用的”。比如某次支付對賬系統(tǒng)升級第三方對賬平臺要求T0實時核驗所有資金流水必須嚴格按時間戳順序處理。我們試過Redis Lua腳本加鎖結果在跨機房網(wǎng)絡抖動時出現(xiàn)鎖續(xù)期失敗導致重復處理換成ZK臨時節(jié)點又因會話超時誤刪鎖引發(fā)數(shù)據(jù)錯亂。最后上線前72小時團隊咬牙切齒地把鎖邏輯全切回MySQL——用SELECT id FROM t_lock WHERE lock_key account_123456 FOR UPDATE配合唯一索引和超時重試扛住了連續(xù)48小時峰值流量零資損。提示這不是技術懷舊而是對“鎖的本質”的回歸。分布式鎖要解決的從來不是“怎么搶”而是“搶完之后誰說了算”。數(shù)據(jù)庫的ACID保證了只要事務沒提交鎖就一定不釋放只要鎖沒釋放其他事務就一定阻塞。這種確定性在網(wǎng)絡不可靠的分布式環(huán)境中比毫秒級的性能數(shù)字更珍貴。所以這篇文章不講“如何用for update實現(xiàn)鎖”而是帶你拆解為什么在特定架構水位下它成了不可替代的兜底方案它的能力邊界在哪里踩過的坑比教科書寫的更痛而填坑的方法論恰恰是架構師真正的護城河。2. FOR UPDATE不是語法糖是InnoDB事務鎖機制的具象化表達很多Java開發(fā)者對SELECT ... FOR UPDATE的理解停留在“加行鎖”三個字上。但當你在生產(chǎn)環(huán)境看到SHOW ENGINE INNODB STATUS里密密麻麻的LOCK WAIT或者information_schema.INNODB_TRX表里卡住的事務列表時就會明白這行SQL背后是InnoDB存儲引擎在內(nèi)存、磁盤、日志三者間精密調(diào)度的鎖管理器在工作。它不是Java代碼調(diào)用的一個API而是數(shù)據(jù)庫內(nèi)核對你業(yè)務語義的強制約束。2.1 行鎖、間隙鎖、臨鍵鎖FOR UPDATE的三種面孔FOR UPDATE到底鎖什么答案取決于WHERE條件和索引結構。這是絕大多數(shù)分布式鎖失效的根源——你以為鎖住了某條記錄其實鎖的是整個索引區(qū)間。精確匹配主鍵或唯一索引SELECT * FROM t_account WHERE id 1001 FOR UPDATE這是最干凈的情況。InnoDB只對id1001這一行加記錄鎖Record Lock。其他事務更新id1002完全不受影響。此時鎖粒度最小性能最好也是我們實現(xiàn)分布式鎖的黃金場景。范圍查詢或非唯一索引SELECT * FROM t_order WHERE user_id 123 FOR UPDATEuser_id無唯一索引InnoDB會升級為臨鍵鎖Next-Key Lock——即“記錄鎖間隙鎖”的組合。它不僅鎖住所有user_id123的行還鎖住這些行之間的“間隙”。比如表中存在user_id為122、123、124的記錄那么(122,123)、(123,124)這兩個間隙也被鎖定。這意味著新插入user_id123.5的訂單會被阻塞。這種鎖的范圍遠超預期極易引發(fā)大面積阻塞。WHERE條件不走索引SELECT * FROM t_log WHERE content LIKE %error% FOR UPDATE沒有索引支撐的查詢InnoDB只能走聚簇索引全表掃描。此時FOR UPDATE會升級為表鎖Table Lock。整個t_log表被鎖死所有DML操作排隊等待。在高并發(fā)系統(tǒng)中這等于直接宣布服務不可用。注意分布式鎖的可靠性90%取決于你能否確保FOR UPDATE始終命中唯一索引。這意味著你的鎖key必須映射到數(shù)據(jù)庫中一條必然存在且唯一的記錄。常見錯誤是直接用業(yè)務ID如訂單號作為鎖key卻未在鎖表中為其建立唯一索引導致鎖失效。2.2 鎖的生命周期從SQL執(zhí)行到事務提交的完整鏈路理解鎖何時加、何時放是避免死鎖和超時的關鍵。FOR UPDATE的鎖行為嚴格綁定事務生命周期加鎖時機SQL執(zhí)行完成、返回結果集的瞬間鎖即生效。不是在Java代碼connection.commit()時才加鎖。持有期間鎖持續(xù)存在于整個事務中包括事務內(nèi)的其他SQL操作、網(wǎng)絡IO等待、業(yè)務邏輯計算耗時。釋放時機僅在事務顯式提交COMMIT或回滾ROLLBACK時釋放。注意JDBC默認自動提交autocommittrue此時FOR UPDATE變成“查完即放”完全失去鎖意義。必須關閉自動提交connection.setAutoCommit(false)。這就引出一個致命陷阱業(yè)務代碼中的try-catch塊如果只捕獲了業(yè)務異常卻忽略了數(shù)據(jù)庫連接中斷、網(wǎng)絡超時等系統(tǒng)級異常會導致事務長期掛起鎖永遠不釋放。我們曾在線上遇到過一個案例某支付回調(diào)接口在處理成功后調(diào)用第三方通知因對方服務超時Java線程卡在httpclient.execute()上長達2小時。由于事務未提交也未回滾FOR UPDATE鎖一直霸占著賬戶記錄導致該用戶后續(xù)所有交易全部阻塞。2.3 死鎖檢測與超時InnoDB給你留的最后退路InnoDB內(nèi)置死鎖檢測器一旦發(fā)現(xiàn)循環(huán)等待如事務A鎖了行1等行2事務B鎖了行2等行1會立即選擇一個事務作為犧牲者victim將其回滾并拋出Deadlock found when trying to get lock異常。這是數(shù)據(jù)庫層面對鎖競爭的主動干預。但死鎖不是唯一風險。更常見的是鎖等待超時。InnoDB通過參數(shù)innodb_lock_wait_timeout控制等待上限默認50秒。當一個事務等待鎖的時間超過此值會拋出Lock wait timeout exceeded異常。這個參數(shù)必須與應用層的超時策略對齊數(shù)據(jù)庫超時設為50秒但Java代碼里Transactional(timeout5)意味著業(yè)務邏輯5秒沒做完就回滾而鎖還在數(shù)據(jù)庫里掛著直到50秒后才釋放。這會造成嚴重的資源浪費。正確的做法是應用層超時 數(shù)據(jù)庫鎖等待超時 事務最大允許耗時。例如支付扣款業(yè)務要求1秒內(nèi)返回那么JavaTransactional(timeout1)MySQLSET innodb_lock_wait_timeout3留2秒緩沖同時在代碼中捕獲LockAcquisitionException快速降級或重試3. 從“能跑通”到“可運維”分布式鎖的七層防御體系用FOR UPDATE寫個Demo可能10分鐘搞定但要讓它在日均億級請求的系統(tǒng)里穩(wěn)定運行三年需要構建一套完整的防御體系。這不是堆砌技術而是對每個環(huán)節(jié)脆弱性的預判和加固。我把這套體系稱為“七層防御”每一層都對應一個真實踩過的坑。3.1 第一層防御鎖表設計——唯一性是生命線鎖表絕不能是簡單的id BIGINT PK, key VARCHAR, expire_time DATETIME。它必須滿足三個硬性條件鎖key字段必須有唯一索引這是FOR UPDATE精準加鎖的前提。沒有唯一索引SELECT ... FOR UPDATE會退化為臨鍵鎖甚至表鎖。鎖表必須是獨立物理表禁止復用業(yè)務表如t_user來存鎖。業(yè)務表結構變更、歷史數(shù)據(jù)歸檔、分庫分表策略都會直接影響鎖的穩(wěn)定性。鎖記錄必須預熱初始化不能依賴INSERT IGNORE或ON DUPLICATE KEY UPDATE動態(tài)創(chuàng)建鎖記錄。因為INSERT本身可能觸發(fā)間隙鎖且在高并發(fā)下易產(chǎn)生主鍵沖突。我們采用的方案是建一張distributed_lock表結構如下CREATE TABLE distributed_lock ( id bigint(20) NOT NULL AUTO_INCREMENT, lock_key varchar(128) NOT NULL COMMENT 鎖的唯一標識如 account_123456, holder varchar(64) NOT NULL COMMENT 持有者標識如 service-a:pid-12345, acquire_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 獲取時間, expire_time datetime NOT NULL COMMENT 過期時間用于被動清理, PRIMARY KEY (id), UNIQUE KEY uk_lock_key (lock_key) -- 強制唯一索引 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT分布式鎖表;關鍵點在于所有鎖key必須在系統(tǒng)啟動時通過腳本批量預插入到表中。例如針對100萬個用戶賬戶預先插入100萬條lock_key為account_000001到account_1000000的記錄。這樣FOR UPDATE永遠只走唯一索引查找鎖粒度嚴格控制在單行。3.2 第二層防御獲取鎖——原子性與冪等性的雙重保障獲取鎖的SQL必須保證“要么成功拿到鎖要么明確失敗”不能有中間態(tài)。常見的錯誤寫法是// ? 危險兩步操作非原子 boolean exists jdbcTemplate.queryForObject( SELECT COUNT(1) FROM distributed_lock WHERE lock_key ?, Integer.class, lockKey) 0; if (!exists) { jdbcTemplate.update(INSERT INTO distributed_lock (lock_key, holder) VALUES (?, ?), lockKey, holder); } jdbcTemplate.update(SELECT * FROM distributed_lock WHERE lock_key ? FOR UPDATE, lockKey);這段代碼在并發(fā)下會因“檢查-創(chuàng)建”非原子導致多線程同時插入違反唯一索引約束拋出DuplicateKeyException。正確姿勢是利用MySQL的INSERT ... ON DUPLICATE KEY UPDATE特性將“檢查-創(chuàng)建-加鎖”三步合一-- ? 原子操作嘗試插入若已存在則更新holder和時間 INSERT INTO distributed_lock (lock_key, holder, acquire_time, expire_time) VALUES (?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? SECOND)) ON DUPLICATE KEY UPDATE holder VALUES(holder), acquire_time VALUES(acquire_time), expire_time VALUES(expire_time); -- 緊接著執(zhí)行FOR UPDATE此時記錄必然存在 SELECT * FROM distributed_lock WHERE lock_key ? FOR UPDATE;Java代碼中我們封裝了一個LockResult對象包含isAcquired(是否成功獲取)、lockId(鎖記錄ID)、holder(持有者)字段。每次獲取鎖后業(yè)務代碼必須校驗isAcquired true才執(zhí)行核心邏輯否則直接返回失敗。3.3 第三層防御鎖續(xù)約——讓長事務不“餓死”分布式鎖常用于處理耗時業(yè)務如生成復雜報表、調(diào)用外部慢接口。如果鎖過期時間設為30秒而業(yè)務邏輯執(zhí)行了45秒鎖會在中途被其他線程搶走導致數(shù)據(jù)不一致。解決方案是“鎖續(xù)約”Lock Renewal在獲取鎖成功后啟動一個后臺守護線程Daemon Thread每隔10秒expire_time/3執(zhí)行一次續(xù)約UPDATE distributed_lock SET expire_time DATE_ADD(NOW(), INTERVAL ? SECOND), holder ? WHERE lock_key ? AND holder ?;續(xù)約必須校驗holder防止誤續(xù)其他線程的鎖CAS思想。守護線程需與業(yè)務線程強綁定業(yè)務線程結束正常完成或異常退出時必須中斷守護線程避免僵尸線程持續(xù)續(xù)約。我們曾因忘記中斷守護線程導致一個已結束的報表任務其守護線程仍在給鎖續(xù)期最終鎖表被占滿新請求全部阻塞。后來在LockResult中增加了close()方法強制在try-with-resources中調(diào)用確保資源及時釋放。3.4 第四層防御鎖釋放——優(yōu)雅退出的最后一步釋放鎖看似簡單DELETE FROM distributed_lock WHERE lock_key ? AND holder ?但有兩個隱藏雷區(qū)釋放操作必須在同一個數(shù)據(jù)庫連接中完成FOR UPDATE加的鎖只能由同一個事務釋放。如果獲取鎖用Connection A釋放用Connection BDELETE語句不會釋放鎖只會刪除記錄導致鎖永久殘留。必須校驗holder防止A線程的鎖被B線程誤刪。這是分布式鎖安全的底線。我們的釋放邏輯強制要求傳入lockId獲取鎖時返回的主鍵ID和holderSQL如下DELETE FROM distributed_lock WHERE id ? AND holder ?;同時在LockResult.close()中我們加入雙重校驗先查SELECT holder FROM distributed_lock WHERE id ?確認當前持有者仍是自己再執(zhí)行DELETE。若第一步查不到或holder不匹配則拋出IllegalLockReleaseException拒絕釋放。3.5 第五層防御異常熔斷——當數(shù)據(jù)庫失聯(lián)時的保命機制數(shù)據(jù)庫連接池耗盡、主庫宕機、網(wǎng)絡分區(qū)……這些故障下FOR UPDATE會直接拋出SQLException如果業(yè)務代碼不做特殊處理整個服務會雪崩。我們的熔斷策略分三級故障類型熔斷動作觸發(fā)條件瞬時抖動快速重試SQLException含Connection refused、Timeout重試2次間隔100ms局部故障降級為本地鎖檢測到連續(xù)5次獲取鎖失敗切換為ConcurrentHashMapReentrantLock僅限同JVM內(nèi)互斥標記告警全局故障熔斷開關打開1分鐘內(nèi)失敗率80%自動打開熔斷器所有鎖請求直接返回LockUnavailableException業(yè)務走異步補償這個熔斷器不是Spring Cloud CircuitBreaker那種通用組件而是專為鎖服務定制的輕量級狀態(tài)機狀態(tài)變更寫入本地AtomicBoolean避免引入額外依賴。3.6 第六層防御監(jiān)控告警——讓鎖的狀態(tài)看得見沒有監(jiān)控的分布式鎖就像蒙眼開車。我們接入了三類核心指標鎖獲取成功率success_count / (success_count fail_count)閾值99.5%觸發(fā)告警。平均等待時間從執(zhí)行FOR UPDATE到返回結果的P95耗時超過200ms告警說明鎖競爭激烈。鎖持有時間分布統(tǒng)計acquire_time到commit的時間繪制直方圖。若大量鎖持有時間集中在30s過期時間說明業(yè)務邏輯超時需優(yōu)化。所有指標通過Micrometer上報到PrometheusGrafana看板實時展示。最關鍵的是一張“鎖熱點圖”橫軸是鎖key的哈希分段如account_000001-010000縱軸是該區(qū)間內(nèi)鎖的平均等待次數(shù)。這張圖能一眼定位出“明星用戶”如某個大V賬號被高頻訪問從而指導分庫分表或緩存預熱。3.7 第七層防御災備清理——鎖表“尸體”的定期掃蕩即使有完善的續(xù)約和釋放機制極端情況下如JVM進程被kill -9仍會產(chǎn)生“僵尸鎖”——鎖記錄存在但holder對應的進程早已消失。我們部署了一個獨立的LockCleaner服務每5分鐘掃描一次SELECT id, lock_key, holder, expire_time FROM distributed_lock WHERE expire_time NOW() - INTERVAL 10 MINUTE; -- 過期10分鐘以上才清理清理前會調(diào)用注冊中心如Nacos查詢holder標識的服務實例是否存活。只有確認實例已下線才執(zhí)行DELETE。這個服務本身也做了高可用多個實例通過數(shù)據(jù)庫SELECT ... FOR UPDATE爭搶清理任務確保同一時刻只有一個實例在工作。4. 性能壓測實錄當QPS從100飆到10000鎖的表現(xiàn)曲線理論再完美也要經(jīng)受真實流量的拷問。我們用JMeter對鎖服務進行了四輪壓測環(huán)境為MySQL 5.7雙主讀寫分離、應用服務器4核8G*3、鎖表數(shù)據(jù)量100萬。結果顛覆了很多人的認知。4.1 基準測試單點MySQL的吞吐天花板第一輪不啟用任何優(yōu)化純FOR UPDATE獲取鎖并發(fā)線程數(shù)QPS平均響應時間(ms)錯誤率關鍵觀察1001200830%表現(xiàn)平穩(wěn)鎖等待幾乎為050021002380.2%出現(xiàn)少量鎖等待平均等待12ms100023004351.8%P95響應時間飆升至1.2s大量請求超時結論單點MySQL的FOR UPDATE鎖獲取理論極限約2300 QPS。超過此值InnoDB的鎖隊列開始積壓響應時間呈指數(shù)增長。這解釋了為什么所有教程都強調(diào)“鎖要細粒度”——不是為了性能而是為了把鎖競爭控制在可承受范圍內(nèi)。4.2 優(yōu)化一擊唯一索引預熱性能翻倍第二輪應用“七層防御”中的第一、二層唯一索引預熱初始化并發(fā)線程數(shù)QPS平均響應時間(ms)錯誤率提升幅度100048002080%QPS提升109%錯誤率歸零200052003850%穩(wěn)定在5000 QPS平臺期關鍵變化SHOW PROFILE FOR QUERY N顯示Sending data階段耗時從320ms降至45ms因為唯一索引使FOR UPDATE的行查找從O(n)降到O(log n)。這證明鎖性能的瓶頸80%不在鎖機制本身而在索引設計。4.3 優(yōu)化二擊讀寫分離連接池調(diào)優(yōu)突破單庫瓶頸第三輪引入讀寫分離FOR UPDATE強制走主庫但鎖表的SELECT如監(jiān)控查詢走從庫。同時調(diào)整HikariCP連接池maximumPoolSize50原20→ 主庫連接更充裕connection-timeout3000原30000→ 避免連接等待過久leak-detection-threshold60000→ 及時發(fā)現(xiàn)連接泄漏并發(fā)線程數(shù)QPS平均響應時間(ms)錯誤率提升幅度200078002560%QPS提升50%響應更平穩(wěn)500082006120.1%逼近主庫CPU 90%紅線此時瓶頸已從鎖機制轉移到主庫CPU和網(wǎng)絡帶寬。我們觀察到SHOW PROCESSLIST中大量Sleep狀態(tài)連接說明應用層連接復用不足。4.4 終極優(yōu)化分庫分表鎖路由支撐萬級QPS第四輪對鎖表進行水平分片。按lock_key哈希如Math.abs(key.hashCode()) % 4分到4個庫每個庫8張表。應用層增加LockRouter組件根據(jù)key自動路由到對應庫表。并發(fā)線程數(shù)QPS平均響應時間(ms)錯誤率架構變化5000125004100%單庫壓力下降75%10000138007200.05%P95時間1.8s仍在業(yè)務容忍內(nèi)支付場景要求3s壓測中最大的發(fā)現(xiàn)是當QPS超過8000FOR UPDATE的性能曲線不再陡峭上升而是呈現(xiàn)平緩的線性增長。這意味著通過分庫分表FOR UPDATE鎖可以線性擴展不存在理論上的絕對瓶頸。某次大促中我們實際承載了15200 QPS鎖獲取成功率99.97%平均耗時890ms完全滿足SLA。5. 與Redis鎖的硬剛對比不是誰更好而是誰更適合你的戰(zhàn)場社區(qū)里關于“數(shù)據(jù)庫鎖 vs Redis鎖”的爭論從未停歇。但作為經(jīng)歷過兩者線上混戰(zhàn)的架構師我想說脫離具體業(yè)務場景談技術選型都是耍流氓。下面這張對比表不是為了分高下而是幫你畫出決策地圖。維度MySQL FOR UPDATE 鎖Redis SETNX 鎖適用場景判斷依據(jù)一致性保證強一致性ACID事務提交前鎖絕不釋放最終一致性網(wǎng)絡分區(qū)時可能出現(xiàn)腦裂兩個客戶端同時認為自己持有鎖金融、支付、庫存扣減等零容忍數(shù)據(jù)錯亂的場景必須選MySQL性能上限單庫約2000-5000 QPS分庫分表后線性擴展單Redis實例輕松支撐5w QPS集群模式更高日均PV1000萬、QPS3000的中小系統(tǒng)Redis更輕量超大規(guī)模且對一致性要求極高MySQL分片是正解運維復雜度依賴DBA需監(jiān)控鎖等待、死鎖、連接池災備需主從切換運維簡單但需關注Redis持久化、哨兵/集群故障轉移、Lua腳本原子性團隊DBA能力強、有成熟MySQL治理平臺選MySQL若團隊更熟悉Redis且能接受最終一致性Redis更省心鎖粒度控制精確到行但依賴索引設計設計不當易升級為間隙鎖/表鎖精確到key無索引概念粒度天然最細業(yè)務鎖key天然具備唯一性如用戶ID、訂單號且能建立唯一索引MySQL優(yōu)勢明顯若鎖key是動態(tài)拼接如cache:user:123:profileRedis更靈活超時與續(xù)約依賴innodb_lock_wait_timeout續(xù)約需額外UPDATE SQL易出錯SET key value EX seconds NX原生支持超時Lua腳本能安全續(xù)約對鎖超時精度要求極高如必須精確到毫秒Redis的EX參數(shù)更可控MySQL的timeout是全局配置不夠靈活故障恢復主庫宕機鎖服務立即不可用從庫只讀無法獲取新鎖Redis Sentinel模式下故障轉移約30秒集群模式更快系統(tǒng)SLA要求RTO30秒且能接受短暫鎖不可用Redis更優(yōu)若要求RTO0如核心支付通道MySQL主庫高可用MHA/PXC是剛需開發(fā)心智負擔需深入理解InnoDB鎖機制、索引原理、事務傳播學習成本高API簡單setIfAbsent文檔豐富上手快新團隊或項目初期優(yōu)先用Redis快速驗證進入穩(wěn)定期、追求極致可靠再遷移到MySQL我們的真實遷移路徑是起步用Redis快中期用MySQL穩(wěn)后期混合智。例如在某電商平臺商品詳情頁的緩存更新用Redis鎖QPS高、容忍短暫不一致而下單扣庫存環(huán)節(jié)強制切到MySQL鎖強一致性。兩個鎖服務通過統(tǒng)一的LockFactory接口注入業(yè)務代碼無感知。注意不要迷信“銀彈”。我們曾在一個內(nèi)容推薦系統(tǒng)中盲目將Redis鎖全換成MySQL鎖結果因推薦算法耗時波動大100ms~3s導致鎖持有時間不可控innodb_lock_wait_timeout設置成3秒太短頻繁超時設成10秒又導致鎖堆積。最后的解法是對耗時穩(wěn)定的業(yè)務用MySQL對耗時波動大的業(yè)務保留Redis鎖但增加“鎖有效性校驗”——在業(yè)務邏輯執(zhí)行前再次GET鎖key確認自己仍是持有者。6. 踩坑實錄那些讓架構師徹夜難眠的鎖相關故障紙上談兵千遍不如一次真實故障的教訓深刻。我把過去三年處理的5個典型鎖故障還原成排查鏈路告訴你當報警電話響起時第一步該看什么。6.1 故障一凌晨2點支付成功率暴跌至30%現(xiàn)象監(jiān)控顯示lock_acquire_success_rate從99.9%驟降至32%大量支付請求卡在FOR UPDATE上P95響應時間從200ms飆升至15s。排查鏈路SHOW PROCESSLIST發(fā)現(xiàn)大量StateUpdating的線程Info列顯示UPDATE distributed_lock SET ... WHERE lock_key account_XXXXXX—— 鎖續(xù)約SQL在執(zhí)行。SELECT * FROM information_schema.INNODB_TRXTRX_STATELOCK WAIT的事務數(shù)超200TRX_MYSQL_THREAD_ID指向應用服務器PID。登錄對應應用服務器jstack PID | grep -A 20 FOR UPDATE線程堆??ㄔ贖ttpURLConnection.getInputStream()正在調(diào)用一個外部風控接口。查distributed_lock表expire_time字段大量為2023-10-01 02:00:00全是整點時間說明續(xù)約線程在固定時間點集中觸發(fā)。根因風控服務在凌晨2點進行例行維護所有請求超時。但我們的鎖續(xù)約線程未設置超時導致UPDATE語句在網(wǎng)絡超時后仍長時間占用數(shù)據(jù)庫連接阻塞了新的鎖獲取請求。修復為所有外部HTTP調(diào)用增加connectTimeout3000、readTimeout5000在續(xù)約線程中增加Thread.interrupt()保護超時后主動中斷將續(xù)約頻率從“固定間隔”改為“基于鎖剩余時間的動態(tài)間隔”剩余時間10s間隔5s剩余時間10s間隔1s6.2 故障二用戶投訴“錢被扣了兩次”現(xiàn)象財務對賬發(fā)現(xiàn)同一筆訂單在10分鐘內(nèi)被扣款兩次t_payment表有兩條相同order_id的記錄。排查鏈路查payment_log兩條記錄create_time相差8秒status均為SUCCESS。查distributed_lock對應lock_keyorder_789012的記錄holder字段為payment-service:12345和payment-service:67890兩個不同PID。SELECT * FROM information_schema.INNODB_LOCK_WAITS空說明沒有死鎖是正常獲取。深挖payment-service:12345的日志在FOR UPDATE后執(zhí)行INSERT INTO t_payment前發(fā)生OutOfMemoryErrorJVM崩潰。payment-service:67890的日志在FOR UPDATE后未檢測到payment-service:12345的鎖持有因進程已死連接斷開鎖被InnoDB自動釋放成功獲取鎖并扣款。根因JVM崩潰導致事務未提交鎖被自動釋放但業(yè)務邏輯未做冪等校驗第二個請求進來后認為自己是首次處理。修復所有扣款接口增加冪等key如order_id timestamp在INSERT t_payment前先SELECT COUNT(1) FROM t_payment WHERE order_id ? AND status SUCCESS在LockResult中增加isHeldByCurrentProcess()方法通過SELECT holder FROM distributed_lock WHERE id ?實時校驗業(yè)務邏輯執(zhí)行前必須調(diào)用6.3 故障三數(shù)據(jù)庫CPU 100%SHOW FULL PROCESSLIST全是Sleep現(xiàn)象MySQL CPU持續(xù)100%SHOW PROCESSLIST顯示數(shù)百個StateSleep的連接Time列顯示連接已空閑3600秒以上。排查鏈路SELECT * FROM information_schema.PROCESSLIST WHERE COMMAND ! Sleep無活躍連接說明問題不在SQL執(zhí)行而在連接管理。SELECT * FROM information_schema.INNODB_TRX空說明沒有長事務。檢查應用服務器netstat -anp | grep :3306 | wc -l顯示連接數(shù)達800遠超HikariCP配置的maximumPoolSize50。jstack PID | grep com.zaxxer.hikari發(fā)現(xiàn)大量線程卡在HikariPool.getConnection()等待連接。根因HikariCP的connection-timeout配置為30秒但應用層Transactional的timeout60。當數(shù)據(jù)庫響應慢時連接在池中等待30秒超時后被丟棄但業(yè)務線程仍在等待事務完成導致連接池不斷創(chuàng)建新連接最終耗盡。修復統(tǒng)一超時Transactional(timeout25)HikariCP.connection-timeout20000innodb_lock_wait_timeout15增加連接池監(jiān)控HikariCP.getMetricsTrackerFactory()上報activeConnections,idleConnections6.4 故障四分庫后部分鎖key總是獲取失敗現(xiàn)象分庫分表上線后約5%的鎖請求返回LockUnavailableException但lock_key在對應庫表中確實存在。排查鏈路抓取失敗的lock_key手動計算哈希Math.abs(account_123456.hashCode()) % 4 2應路由到ds_2庫。登錄ds_2庫SELECT * FROM distributed_lock_0 WHERE lock_key account_123456記錄存在。EXPLAIN SELECT * FROM distributed_lock_0 WHERE lock_key account_123456 FOR UPDATEtypeALL未走索引查distributed_lock_0表結構lock_key字段為varchar(128)但建表時未指定字符集MySQL 5.7默認utf8而應用傳入的key是utf8mb4編碼。根因字符集不匹配導致索引失效。utf8mb4的account_123456在utf8索引中無法匹配FOR UPDATE退化為全表掃描觸發(fā)臨鍵鎖最終因鎖等待超時失敗。修復ALTER TABLE distributed_lock_0 CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci所有分表執(zhí)行ALTER TABLE ... MODIFY COLUMN lock_key VARCHAR(128) CHARACTER SET utf8mb4 NOT NULL6.5 故障五大促期間鎖表被“撐爆”現(xiàn)象大促高峰distributed_lock表數(shù)據(jù)量從100萬暴漲到5000萬磁盤使用率95%INSERT操作變慢。排查鏈路SELECT table_name, table_rows, data_length FROM information_schema.TABLES WHERE table_schema xxx AND table_name LIKE distributed_lock%單表行數(shù)超600萬。