[{"data":1,"prerenderedAt":36347},["ShallowReactive",2],{"articles-list":3},[4,1169,1459,1913,2303,2760,3041,3905,4567,5901,6890,7824,8334,8967,10106,11069,11787,12403,13674,15507,16289,16894,17905,18438,19843,21718,22993,25194,25675,26423,27017,27697,28899,30084,30963,31804,32578,34006,35262],{"id":5,"title":6,"body":7,"date":1154,"description":1155,"excerpt":1156,"extension":1157,"meta":1158,"navigation":1035,"path":1159,"seo":1160,"stem":1161,"tags":1162,"__hash__":1168},"articles\u002Farticles\u002Fk8s-hpa.md","Kubernetes HPA 為什麼看 Request 而不是 Limit？從擴展公式反推 CPU Request／Limit 怎麼設",{"type":8,"value":9,"toc":1135},"minimark",[10,19,27,32,35,45,56,62,65,95,99,104,115,124,128,131,181,188,192,199,203,210,239,243,248,270,273,365,368,458,644,647,654,744,748,794,798,805,823,826,906,913,917,924,938,948,952,959,973,976,1000,1004,1078,1093,1096,1131],[11,12,13,14,18],"p",{},"設定 HPA（Horizontal Pod Autoscaler）時，最常被問的一個問題是：「為什麼 ",[15,16,17],"code",{},"averageUtilization: 75"," 的 75% 是相對 Request 算，而不是相對 Limit？Limit 才是 Pod 真正能用到的上限啊。」",[11,20,21,22,26],{},"這個問題背後其實藏著 HPA 的設計哲學：",[23,24,25],"strong",{},"HPA 不是在 Pod 快撐不住時才救火，而是在用量超過「預期常態」的某個比例時，就提早擴展","，替新 Pod 的啟動與就緒爭取緩衝時間。理解這點之後，Request、Limit、HPA Target 三個參數該怎麼設，就會變成一個可以推導的問題，而不是憑感覺填數字。",[28,29,31],"h2",{"id":30},"先看-hpa-到底怎麼算","先看 HPA 到底怎麼算",[11,33,34],{},"HPA 控制器預設每 15 秒執行一次，從 metrics-server 取得每個 Pod 的 CPU 用量，然後套用這條公式：",[36,37,42],"pre",{"className":38,"code":40,"language":41},[39],"language-text","desiredReplicas = ceil( currentReplicas × currentUtilization \u002F targetUtilization )\n\ncurrentUtilization = 所有 Pod 實際 CPU 用量的平均 ÷ 每個 Pod 的 CPU Request\n","text",[15,43,40],{"__ignoreMap":44},"",[11,46,47,48,51,52,55],{},"舉例：目前 2 個 Pod、Request 為 ",[15,49,50],{},"800m","、Target 為 75%，兩個 Pod 平均用了 ",[15,53,54],{},"1000m","：",[36,57,60],{"className":58,"code":59,"language":41},[39],"currentUtilization = 1000m \u002F 800m = 125%\ndesiredReplicas    = ceil( 2 × 125 \u002F 75 ) = ceil(3.33) = 4\n",[15,61,59],{"__ignoreMap":44},[11,63,64],{},"從公式可以讀出幾個重要的事實：",[66,67,68,75,85],"ul",{},[69,70,71,74],"li",{},[23,72,73],{},"分母是 Request，Limit 完全不參與計算。"," 使用率可以超過 100%（上例就是 125%），因為 Pod 被允許用到 Request 以上、Limit 以下的空間。",[69,76,77,80,81,84],{},[23,78,79],{},"沒有設定 CPU Request 的 Pod，HPA 算不出使用率。"," 這時 HPA 會顯示 ",[15,82,83],{},"\u003Cunknown>","，完全不會擴展。如果 Pod 裡有 sidecar（例如 Istio 的 envoy），sidecar 也要設 Request，因為使用率是整個 Pod 所有容器的加總。",[69,86,87,90,91,94],{},[23,88,89],{},"有 10% 的容忍區間。"," 當 ",[15,92,93],{},"currentUtilization \u002F targetUtilization"," 落在 0.9～1.1 之間時，HPA 不會動作，避免在目標值附近來回抖動。",[28,96,98],{"id":97},"為什麼基於-request-而不是-limit","為什麼基於 Request 而不是 Limit？",[100,101,103],"h3",{"id":102},"_1-用-limit-算擴展時機一定太晚","1. 用 Limit 算，擴展時機一定太晚",[11,105,106,107,110,111,114],{},"Limit 是硬性天花板。CPU 用量碰到 Limit 時，Linux 的 CFS 排程器會開始 ",[23,108,109],{},"Throttling","——容器在每個 100ms 週期內用完配額就被強制暫停，反映到應用程式上就是延遲突然飆高、timeout 開始出現。記憶體碰到 Limit 則更直接，容器會被 ",[23,112,113],{},"OOMKilled","。",[11,116,117,118,123],{},"而新 Pod 從被建立到真正能接流量，要經過排程、拉 image、應用程式啟動、通過 Readiness Probe，常常要 1～3 分鐘（探針怎麼設計可以參考",[119,120,122],"a",{"href":121},"\u002Farticles\u002Fdotnet-k8s-health-checks","《Kubernetes 探針怎麼設計？》","）。如果等到接近 Limit 才觸發擴展，這段空窗期裡舊 Pod 早就被節流或砍掉了。",[100,125,127],{"id":126},"_2-request-才是預期常態用量","2. Request 才是「預期常態用量」",[11,129,130],{},"在容量規劃上，兩個參數的角色是這樣分工的：",[132,133,134,151],"table",{},[135,136,137],"thead",{},[138,139,140,145,148],"tr",{},[141,142,144],"th",{"align":143},"left","參數",[141,146,147],{"align":143},"角色",[141,149,150],{"align":143},"意義",[152,153,154,168],"tbody",{},[138,155,156,162,165],{},[157,158,159],"td",{"align":143},[23,160,161],{},"Request",[157,163,164],{"align":143},"保證水位",[157,166,167],{"align":143},"應用程式在正常流量下的基準消耗量，Kubernetes 保證一定分得到",[138,169,170,175,178],{},[157,171,172],{"align":143},[23,173,174],{},"Limit",[157,176,177],{"align":143},"突發容量（Burst capacity）",[157,179,180],{"align":143},"應對短時間尖峰的彈性空間，不保證一定分得到",[11,182,183,184,187],{},"HPA 的邏輯因此很自然：",[23,185,186],{},"用量超過常態基準的某個比例，就該加副本了","；而加副本的這段時間，超出的負載由 Request 到 Limit 之間的彈性空間來吸收。",[100,189,191],{"id":190},"_3-與-scheduler-的資源記帳方式一致","3. 與 Scheduler 的資源記帳方式一致",[11,193,194,195,198],{},"Kubernetes Scheduler 決定 Pod 要放到哪個 Node 時，看的是 ",[23,196,197],{},"Request 的加總","，而不是 Limit，也不是實際用量。HPA 也用 Request 計算，代表「HPA 決定加幾個 Pod」跟「叢集能不能放得下這些 Pod」用的是同一套帳本——HPA 算出要 4 個 Pod，Scheduler 就準確知道需要 4 × 800m 的可分配空間。",[28,200,202],{"id":201},"常見疑慮流量長期大於-request會不會無限擴展","常見疑慮：流量長期大於 Request，會不會無限擴展？",[11,204,205,206,209],{},"會一直擴展，但這是 HPA ",[23,207,208],{},"預期中","的健康行為——流量本來就超過基礎水位了，系統本來就該增加副本數來攤平負載。要防止的不是「擴展」本身，而是「擴展到把叢集資源榨乾」，正確的作法是：",[211,212,213,223,229],"ol",{},[69,214,215,222],{},[23,216,217,218,221],{},"設定 ",[15,219,220],{},"maxReplicas"," 上限","，並確認叢集（或 Cluster Autoscaler 的 Node 上限）真的放得下這麼多副本。",[69,224,225,228],{},[23,226,227],{},"設定合理的 Target Utilization","，一般落在 60%～80%。越低代表擴得越早、越保守，但閒置成本越高。",[69,230,231,234,235,238],{},[23,232,233],{},"Request 要貼近實際常態用量。"," 設得極低（例如為了塞更多 Pod 而只給 ",[15,236,237],{},"100m","），HPA 就會在常態流量下一直以為使用率爆表，副本數永遠頂在上限。",[28,240,242],{"id":241},"實戰情境常態-05-core峰值-2-core-怎麼設","實戰情境：常態 0.5 core、峰值 2 core 怎麼設",[11,244,245],{},[23,246,247],{},"情境假設（單一 Pod 的用量）：",[66,249,250,257,263],{},[69,251,252,253,256],{},"常態用量：",[15,254,255],{},"500m","（0.5 core）",[69,258,259,260,262],{},"流量翻倍：",[15,261,54],{},"（1 core）",[69,264,265,266,269],{},"極端峰值：",[15,267,268],{},"2000m","（2 core，常態的四倍）",[100,271,272],{"id":272},"推薦參數配置",[132,274,275,287],{},[135,276,277],{},[138,278,279,281,284],{},[141,280,144],{"align":143},[141,282,283],{"align":143},"建議設定值",[141,285,286],{"align":143},"說明",[152,288,289,303,317,336,351],{},[138,290,291,296,300],{},[157,292,293],{"align":143},[23,294,295],{},"CPU Request",[157,297,298],{"align":143},[15,299,50],{},[157,301,302],{"align":143},"排程的保證水位，也是 HPA 計算使用率的分母",[138,304,305,310,314],{},[157,306,307],{"align":143},[23,308,309],{},"CPU Limit",[157,311,312],{"align":143},[15,313,268],{},[157,315,316],{"align":143},"硬限制，用來吸收極端峰值，避免新 Pod 啟動空窗期被 Throttling",[138,318,319,324,329],{},[157,320,321],{"align":143},[23,322,323],{},"HPA Target",[157,325,326],{"align":143},[15,327,328],{},"75%",[157,330,331,332,335],{"align":143},"平均用量達到 ",[15,333,334],{},"600m","（800m × 75%）時開始擴展",[138,337,338,343,348],{},[157,339,340],{"align":143},[23,341,342],{},"minReplicas",[157,344,345],{"align":143},[15,346,347],{},"2",[157,349,350],{"align":143},"至少兩個副本，單一 Pod 掛掉時服務不中斷",[138,352,353,357,362],{},[157,354,355],{"align":143},[23,356,220],{},[157,358,359],{"align":143},[15,360,361],{},"10",[157,363,364],{"align":143},"依叢集容量與下游（DB、外部 API）能承受的併發數決定",[11,366,367],{},"對應的 YAML：",[36,369,373],{"className":370,"code":371,"language":372,"meta":44,"style":44},"language-yaml shiki shiki-themes github-dark","# deployment.yaml（節錄）\nresources:\n  requests:\n    cpu: 800m\n    memory: 512Mi\n  limits:\n    cpu: 2000m\n    memory: 512Mi   # 記憶體建議 Request = Limit，原因見後文\n","yaml",[15,374,375,384,395,403,416,427,435,445],{"__ignoreMap":44},[376,377,380],"span",{"class":378,"line":379},"line",1,[376,381,383],{"class":382},"sAwPA","# deployment.yaml（節錄）\n",[376,385,387,391],{"class":378,"line":386},2,[376,388,390],{"class":389},"s4JwU","resources",[376,392,394],{"class":393},"s95oV",":\n",[376,396,398,401],{"class":378,"line":397},3,[376,399,400],{"class":389},"  requests",[376,402,394],{"class":393},[376,404,406,409,412],{"class":378,"line":405},4,[376,407,408],{"class":389},"    cpu",[376,410,411],{"class":393},": ",[376,413,415],{"class":414},"sU2Wk","800m\n",[376,417,419,422,424],{"class":378,"line":418},5,[376,420,421],{"class":389},"    memory",[376,423,411],{"class":393},[376,425,426],{"class":414},"512Mi\n",[376,428,430,433],{"class":378,"line":429},6,[376,431,432],{"class":389},"  limits",[376,434,394],{"class":393},[376,436,438,440,442],{"class":378,"line":437},7,[376,439,408],{"class":389},[376,441,411],{"class":393},[376,443,444],{"class":414},"2000m\n",[376,446,448,450,452,455],{"class":378,"line":447},8,[376,449,421],{"class":389},[376,451,411],{"class":393},[376,453,454],{"class":414},"512Mi",[376,456,457],{"class":382},"   # 記憶體建議 Request = Limit，原因見後文\n",[36,459,461],{"className":370,"code":460,"language":372,"meta":44,"style":44},"# hpa.yaml\napiVersion: autoscaling\u002Fv2\nkind: HorizontalPodAutoscaler\nmetadata:\n  name: order-api\nspec:\n  scaleTargetRef:\n    apiVersion: apps\u002Fv1\n    kind: Deployment\n    name: order-api\n  minReplicas: 2\n  maxReplicas: 10\n  metrics:\n    - type: Resource\n      resource:\n        name: cpu\n        target:\n          type: Utilization\n          averageUtilization: 75\n",[15,462,463,468,478,488,495,505,512,519,529,540,550,562,573,581,595,603,614,622,633],{"__ignoreMap":44},[376,464,465],{"class":378,"line":379},[376,466,467],{"class":382},"# hpa.yaml\n",[376,469,470,473,475],{"class":378,"line":386},[376,471,472],{"class":389},"apiVersion",[376,474,411],{"class":393},[376,476,477],{"class":414},"autoscaling\u002Fv2\n",[376,479,480,483,485],{"class":378,"line":397},[376,481,482],{"class":389},"kind",[376,484,411],{"class":393},[376,486,487],{"class":414},"HorizontalPodAutoscaler\n",[376,489,490,493],{"class":378,"line":405},[376,491,492],{"class":389},"metadata",[376,494,394],{"class":393},[376,496,497,500,502],{"class":378,"line":418},[376,498,499],{"class":389},"  name",[376,501,411],{"class":393},[376,503,504],{"class":414},"order-api\n",[376,506,507,510],{"class":378,"line":429},[376,508,509],{"class":389},"spec",[376,511,394],{"class":393},[376,513,514,517],{"class":378,"line":437},[376,515,516],{"class":389},"  scaleTargetRef",[376,518,394],{"class":393},[376,520,521,524,526],{"class":378,"line":447},[376,522,523],{"class":389},"    apiVersion",[376,525,411],{"class":393},[376,527,528],{"class":414},"apps\u002Fv1\n",[376,530,532,535,537],{"class":378,"line":531},9,[376,533,534],{"class":389},"    kind",[376,536,411],{"class":393},[376,538,539],{"class":414},"Deployment\n",[376,541,543,546,548],{"class":378,"line":542},10,[376,544,545],{"class":389},"    name",[376,547,411],{"class":393},[376,549,504],{"class":414},[376,551,553,556,558],{"class":378,"line":552},11,[376,554,555],{"class":389},"  minReplicas",[376,557,411],{"class":393},[376,559,561],{"class":560},"sDLfK","2\n",[376,563,565,568,570],{"class":378,"line":564},12,[376,566,567],{"class":389},"  maxReplicas",[376,569,411],{"class":393},[376,571,572],{"class":560},"10\n",[376,574,576,579],{"class":378,"line":575},13,[376,577,578],{"class":389},"  metrics",[376,580,394],{"class":393},[376,582,584,587,590,592],{"class":378,"line":583},14,[376,585,586],{"class":393},"    - ",[376,588,589],{"class":389},"type",[376,591,411],{"class":393},[376,593,594],{"class":414},"Resource\n",[376,596,598,601],{"class":378,"line":597},15,[376,599,600],{"class":389},"      resource",[376,602,394],{"class":393},[376,604,606,609,611],{"class":378,"line":605},16,[376,607,608],{"class":389},"        name",[376,610,411],{"class":393},[376,612,613],{"class":414},"cpu\n",[376,615,617,620],{"class":378,"line":616},17,[376,618,619],{"class":389},"        target",[376,621,394],{"class":393},[376,623,625,628,630],{"class":378,"line":624},18,[376,626,627],{"class":389},"          type",[376,629,411],{"class":393},[376,631,632],{"class":414},"Utilization\n",[376,634,636,639,641],{"class":378,"line":635},19,[376,637,638],{"class":389},"          averageUtilization",[376,640,411],{"class":393},[376,642,643],{"class":560},"75\n",[100,645,646],{"id":646},"套進公式驗算一次",[11,648,649,650,653],{},"假設 ",[15,651,652],{},"minReplicas: 2","，下表是不同流量下 HPA 的反應（「單 Pod 用量」指擴展前每個 Pod 承受的量）：",[132,655,656,675],{},[135,657,658],{},[138,659,660,663,666,669,672],{},[141,661,662],{"align":143},"流量情境",[141,664,665],{"align":143},"單 Pod 用量",[141,667,668],{"align":143},"使用率",[141,670,671],{"align":143},"HPA 算出的副本數",[141,673,674],{"align":143},"擴展後單 Pod 用量",[152,676,677,696,719],{},[138,678,679,682,686,689,692],{},[157,680,681],{"align":143},"常態",[157,683,684],{"align":143},[15,685,255],{},[157,687,688],{"align":143},"62.5%",[157,690,691],{"align":143},"2（不動作）",[157,693,694],{"align":143},[15,695,255],{},[138,697,698,701,705,708,714],{},[157,699,700],{"align":143},"翻倍",[157,702,703],{"align":143},[15,704,54],{},[157,706,707],{"align":143},"125%",[157,709,710,711],{"align":143},"ceil(2 × 125 \u002F 75) = ",[23,712,713],{},"4",[157,715,716,718],{"align":143},[15,717,255],{},"（62.5%）",[138,720,721,724,728,731,737],{},[157,722,723],{"align":143},"極端峰值",[157,725,726],{"align":143},[15,727,268],{},[157,729,730],{"align":143},"250%",[157,732,733,734],{"align":143},"ceil(2 × 250 \u002F 75) = ",[23,735,736],{},"7",[157,738,739,740,743],{"align":143},"約 ",[15,741,742],{},"571m","（71%）",[100,745,747],{"id":746},"為什麼這樣設定最合理","為什麼這樣設定最合理？",[211,749,750,775,784],{},[69,751,752,755,756,758,759,762,763,765,766,768,769,771,772,774],{},[23,753,754],{},"常態用量不會觸發無效擴展。","\n如果 Request 照常態用量設成 ",[15,757,255],{},"、Target 80%，觸發點是 ",[15,760,761],{},"400m","，常態的 ",[15,764,255],{}," 就已經是 125% 使用率，HPA 會在平常就不斷擴展。把 Request 提高到 ",[15,767,50],{},"，觸發點落在 ",[15,770,334],{},"，常態 ",[15,773,255],{}," 時使用率只有 62.5%，系統會安穩維持在最小副本數。",[69,776,777,780,781,783],{},[23,778,779],{},"流量翻倍時平順擴展。","\n用量跨過 ",[15,782,334],{}," 往 1 core 走時 HPA 就開始擴展，這時離 Limit（2 core）還有一倍的空間，舊 Pod 完全不會被節流，使用者感受不到擴展正在發生。",[69,785,786,789,790,793],{},[23,787,788],{},"極端峰值時，Limit 負責掩護空窗期。","\n四倍流量瞬間湧入時，HPA 偵測 + 新 Pod 啟動通常要 1～3 分鐘。這段時間舊 Pod 靠 ",[15,791,792],{},"limit: 2000m"," 直接 Burst 到 2 core 消化請求；等新 Pod 就緒、流量被攤平，每個 Pod 的用量又回到 70% 上下的健康水位。",[28,795,797],{"id":796},"擴縮的節奏behavior-設定","擴縮的節奏：behavior 設定",[11,799,800,801,804],{},"HPA 預設的擴縮節奏是「",[23,802,803],{},"擴得快、縮得慢","」：",[66,806,807,813],{},[69,808,809,812],{},[23,810,811],{},"Scale Up","：沒有穩定窗口，每 15 秒最多可以翻倍（或多加 4 個 Pod，取較大者）。",[69,814,815,818,819,822],{},[23,816,817],{},"Scale Down","：有 ",[23,820,821],{},"300 秒","的穩定窗口——HPA 會取過去 5 分鐘內算出的最大建議值，避免流量一掉下去就馬上縮、下一波尖峰又要重新擴。",[11,824,825],{},"大多數情境用預設值就夠了。如果服務的流量是「尖峰很短、但一小時來好幾次」的鋸齒型，可以把縮容放得更保守：",[36,827,829],{"className":370,"code":828,"language":372,"meta":44,"style":44},"spec:\n  behavior:\n    scaleDown:\n      stabilizationWindowSeconds: 600   # 觀察 10 分鐘才縮\n      policies:\n        - type: Pods\n          value: 1                      # 每分鐘最多縮 1 個\n          periodSeconds: 60\n",[15,830,831,837,844,851,864,871,883,896],{"__ignoreMap":44},[376,832,833,835],{"class":378,"line":379},[376,834,509],{"class":389},[376,836,394],{"class":393},[376,838,839,842],{"class":378,"line":386},[376,840,841],{"class":389},"  behavior",[376,843,394],{"class":393},[376,845,846,849],{"class":378,"line":397},[376,847,848],{"class":389},"    scaleDown",[376,850,394],{"class":393},[376,852,853,856,858,861],{"class":378,"line":405},[376,854,855],{"class":389},"      stabilizationWindowSeconds",[376,857,411],{"class":393},[376,859,860],{"class":560},"600",[376,862,863],{"class":382},"   # 觀察 10 分鐘才縮\n",[376,865,866,869],{"class":378,"line":418},[376,867,868],{"class":389},"      policies",[376,870,394],{"class":393},[376,872,873,876,878,880],{"class":378,"line":429},[376,874,875],{"class":393},"        - ",[376,877,589],{"class":389},[376,879,411],{"class":393},[376,881,882],{"class":414},"Pods\n",[376,884,885,888,890,893],{"class":378,"line":437},[376,886,887],{"class":389},"          value",[376,889,411],{"class":393},[376,891,892],{"class":560},"1",[376,894,895],{"class":382},"                      # 每分鐘最多縮 1 個\n",[376,897,898,901,903],{"class":378,"line":447},[376,899,900],{"class":389},"          periodSeconds",[376,902,411],{"class":393},[376,904,905],{"class":560},"60\n",[11,907,908,909,912],{},"另外要注意：HPA 對",[23,910,911],{},"剛啟動的 Pod"," 有保護機制——Pod 還沒 Ready、或剛啟動時（預設 5 分鐘內）的 CPU 數據會被保守處理，避免 JIT 編譯、快取預熱這種啟動期的 CPU 尖峰讓 HPA 誤以為負載很高、連鎖擴展。這也是 Readiness Probe 要設計正確的另一個理由。",[28,914,916],{"id":915},"記憶體為什麼不建議拿來當-hpa-指標","記憶體為什麼不建議拿來當 HPA 指標？",[11,918,919,920,923],{},"HPA 也支援 ",[15,921,922],{},"memory"," 指標，但對 .NET、Java 這類有 GC 的執行環境通常不適合：",[66,925,926,932],{},[69,927,928,931],{},[23,929,930],{},"記憶體用量不會隨流量下降而回落。"," GC 回收後，執行環境常常不會把記憶體還給作業系統，擴上去之後使用率一直維持高檔，HPA 就永遠縮不回來。",[69,933,934,937],{},[23,935,936],{},"記憶體不可壓縮。"," CPU 超用只會被節流變慢，記憶體超過 Limit 是直接被 OOMKilled，沒有「Burst 一下撐過去」的空間。",[11,939,940,941,947],{},"所以一般建議是：",[23,942,943,944],{},"CPU 用 HPA 彈性擴展，記憶體則設 ",[15,945,946],{},"Request = Limit","，讓每個 Pod 拿到的記憶體是確定的，排程與 OOM 行為都可預期。如果業務上真正的瓶頸是請求量、佇列長度（例如 Kafka consumer lag），比起記憶體，更值得透過 Prometheus Adapter 或 KEDA 改用自訂指標來擴展。",[28,949,951],{"id":950},"️-架構師視角的提醒overcommit-風險","⚠️ 架構師視角的提醒：Overcommit 風險",[11,953,954,955,958],{},"上面的設定中，CPU Request（0.8）與 Limit（2.0）差距達 2.5 倍，這代表高度依賴 Kubernetes 的 ",[23,956,957],{},"Overcommit（超賣）"," 機制：Scheduler 只依 Request 把 Pod 塞進 Node，Node 上所有 Pod 的 Limit 加總可以遠大於 Node 的實體 CPU。",[66,960,961,967],{},[69,962,963,966],{},[23,964,965],{},"優點","：應對偶發峰值非常有效，平常不用為峰值預留整顆 CPU，資源利用率高。",[69,968,969,972],{},[23,970,971],{},"風險","：如果同一個 Node 上的多個 Pod「同時」迎來峰值，Node 的實體 CPU 不夠分，大家會依 Request 的比例分配 CPU 時間，結果是每個 Pod 都拿不到它以為能用的 2 core，延遲一起上升。",[11,974,975],{},"實務上的因應方式：",[211,977,978,984,994],{},[69,979,980,983],{},[23,981,982],{},"監控 Node 層級的 CPU 使用率","，不只是 Pod 層級。Node 長期高於 70%～80%，代表 Burst 空間已經被吃掉了。",[69,985,986,989,990,993],{},[23,987,988],{},"同一個服務的副本要分散到不同 Node。"," 用 ",[15,991,992],{},"topologySpreadConstraints"," 或 Pod Anti-Affinity，避免「同一波流量」讓擠在同一台 Node 上的副本同時 Burst。",[69,995,996,999],{},[23,997,998],{},"核心服務考慮縮小 Request／Limit 差距","，甚至設成 Guaranteed QoS（Request = Limit），用成本換取可預期性；非核心、可容忍變慢的服務，才放大 Overcommit 的比例。",[28,1001,1003],{"id":1002},"上線後怎麼確認-hpa-有正常運作","上線後怎麼確認 HPA 有正常運作",[36,1005,1009],{"className":1006,"code":1007,"language":1008,"meta":44,"style":44},"language-bash shiki shiki-themes github-dark","# 看目前使用率 \u002F 目標值與副本數，TARGETS 若是 \u003Cunknown> 通常是沒設 Request 或 metrics-server 異常\nkubectl get hpa order-api\n\n# 看 HPA 的事件與每次擴縮的原因\nkubectl describe hpa order-api\n\n# 看每個 Pod 的實際 CPU \u002F 記憶體用量\nkubectl top pod -l app=order-api\n","bash",[15,1010,1011,1016,1031,1037,1042,1053,1057,1062],{"__ignoreMap":44},[376,1012,1013],{"class":378,"line":379},[376,1014,1015],{"class":382},"# 看目前使用率 \u002F 目標值與副本數，TARGETS 若是 \u003Cunknown> 通常是沒設 Request 或 metrics-server 異常\n",[376,1017,1018,1022,1025,1028],{"class":378,"line":386},[376,1019,1021],{"class":1020},"svObZ","kubectl",[376,1023,1024],{"class":414}," get",[376,1026,1027],{"class":414}," hpa",[376,1029,1030],{"class":414}," order-api\n",[376,1032,1033],{"class":378,"line":397},[376,1034,1036],{"emptyLinePlaceholder":1035},true,"\n",[376,1038,1039],{"class":378,"line":405},[376,1040,1041],{"class":382},"# 看 HPA 的事件與每次擴縮的原因\n",[376,1043,1044,1046,1049,1051],{"class":378,"line":418},[376,1045,1021],{"class":1020},[376,1047,1048],{"class":414}," describe",[376,1050,1027],{"class":414},[376,1052,1030],{"class":414},[376,1054,1055],{"class":378,"line":429},[376,1056,1036],{"emptyLinePlaceholder":1035},[376,1058,1059],{"class":378,"line":437},[376,1060,1061],{"class":382},"# 看每個 Pod 的實際 CPU \u002F 記憶體用量\n",[376,1063,1064,1066,1069,1072,1075],{"class":378,"line":447},[376,1065,1021],{"class":1020},[376,1067,1068],{"class":414}," top",[376,1070,1071],{"class":414}," pod",[376,1073,1074],{"class":560}," -l",[376,1076,1077],{"class":414}," app=order-api\n",[11,1079,1080,1081,1084,1085,1088,1089,1092],{},"如果有接 Prometheus，建議再觀察 ",[15,1082,1083],{},"container_cpu_cfs_throttled_periods_total"," 對 ",[15,1086,1087],{},"container_cpu_cfs_periods_total"," 的比例——",[23,1090,1091],{},"Throttling 比例持續偏高，代表 Limit 太緊，或 HPA 擴得太慢","，即使平均 CPU 使用率看起來很健康也一樣。",[28,1094,1095],{"id":1095},"總結",[66,1097,1098,1105,1111,1117,1125],{},[69,1099,1100,1101,1104],{},"HPA 的使用率是 ",[23,1102,1103],{},"實際用量 ÷ Request","，Limit 不參與計算；沒設 Request，HPA 就不會動。",[69,1106,1107,1110],{},[23,1108,1109],{},"Request 設成「常態用量 ÷ Target」再留一點餘裕","，常態流量才不會觸發無效擴展（本例：500m ÷ 75% ≈ 667m，取 800m）。",[69,1112,1113,1116],{},[23,1114,1115],{},"Limit 的任務是掩護擴展空窗期","，讓舊 Pod 在新 Pod 就緒前能 Burst 撐住峰值。",[69,1118,1119,1124],{},[23,1120,1121,1123],{},[15,1122,220],{}," 才是防止無限擴展的開關","，而不是把 Request 設高或 Target 設高。",[69,1126,1127,1128,1130],{},"CPU 適合 HPA，記憶體建議 ",[15,1129,946],{},"；Request 與 Limit 差距越大，越要盯緊 Node 層級的資源與副本分散。",[1132,1133,1134],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}",{"title":44,"searchDepth":386,"depth":386,"links":1136},[1137,1138,1143,1144,1149,1150,1151,1152,1153],{"id":30,"depth":386,"text":31},{"id":97,"depth":386,"text":98,"children":1139},[1140,1141,1142],{"id":102,"depth":397,"text":103},{"id":126,"depth":397,"text":127},{"id":190,"depth":397,"text":191},{"id":201,"depth":386,"text":202},{"id":241,"depth":386,"text":242,"children":1145},[1146,1147,1148],{"id":272,"depth":397,"text":272},{"id":646,"depth":397,"text":646},{"id":746,"depth":397,"text":747},{"id":796,"depth":386,"text":797},{"id":915,"depth":386,"text":916},{"id":950,"depth":386,"text":951},{"id":1002,"depth":386,"text":1003},{"id":1095,"depth":386,"text":1095},"2026-10-07","設定 HPA（Horizontal Pod Autoscaler）時，最常被問的一個問題是：「為什麼 averageUtilization: 75 的 75% 是相對 Request 算，而不是相對 Limit？Limit 才是 Pod 真正能用到的上限啊。」",null,"md",{},"\u002Farticles\u002Fk8s-hpa",{"title":6,"description":1155},"articles\u002Fk8s-hpa",[1163,1164,1165,1166,1167],"Kubernetes","HPA","Autoscaling","SRE","容量規劃","hTVrarh1tSnfhO1cXy4RE8RtCtkjVdBT_3XPxqDlYJM",{"id":1170,"title":1171,"body":1172,"date":1448,"description":44,"excerpt":1156,"extension":1157,"meta":1449,"navigation":1035,"path":1450,"seo":1451,"stem":1452,"tags":1453,"__hash__":1458},"articles\u002Farticles\u002Fdevops-calms-framework.md","DevOps CALMS 實踐框架：不是先買工具，而是先看哪個字母在拖後腿",{"type":8,"value":1173,"toc":1439},[1174,1178,1181,1184,1187,1191,1197,1205,1208,1212,1218,1221,1241,1244,1248,1251,1271,1274,1278,1281,1284,1342,1345,1349,1352,1372,1375,1379,1382,1436],[28,1175,1177],{"id":1176},"calms-是拿來自我檢查的框架不是驗收清單","CALMS 是拿來自我檢查的框架，不是驗收清單",[11,1179,1180],{},"CALMS 這個字，是 Culture（文化）、Automation（自動化）、Lean（精實）、Measurement（量測）、Sharing（分享）五個字的縮寫，最早由 Jez Humble 在《The DevOps Handbook》系列的討論中提出，用來描述一個組織要走向 DevOps 需要同時成立的五個條件。",[11,1182,1183],{},"它常被誤用成一份「導入清單」——CI\u002FCD 裝好、Grafana 儀表板做好、就打勾說「我們 DevOps 了」。但這五個字母彼此互為前提：沒有文化上「出事不究責」的安全感，自動化只會把犯錯的速度變快；沒有量測，精實改善無從驗證；沒有分享，前面四項全部卡在少數人身上，離開就斷層。",[11,1185,1186],{},"真正有用的用法，是拿它當一份自我診斷表：團隊現在踩了哪個字母的坑，資源就該優先投在那裡，而不是每個字母都均勻地花錢買工具。",[28,1188,1190],{"id":1189},"culture先問出事之後第一個問題是誰還是為什麼","Culture：先問「出事之後，第一個問題是誰還是為什麼」",[11,1192,1193,1194],{},"文化是五個字裡最抽象、卻決定其他四個能不能落地的一項。實務上最快分辨團隊文化健不健康的問題是：",[23,1195,1196],{},"發生 Incident 之後，會議室裡第一句話是「這是誰改的」還是「發生了什麼、我們怎麼避免下次再發生」。",[66,1198,1199,1202],{},[69,1200,1201],{},"前者是究責文化（Blame Culture），工程師學到的教訓是「少改東西、少扛風險」，長期結果是沒人敢碰核心系統，變更集中在少數「敢做」的人身上",[69,1203,1204],{},"後者是無究責事後檢討（Blameless Postmortem）文化，把焦點放在系統性原因——為什麼這個錯誤沒有被測試攔下來、為什麼監控沒有更早示警、為什麼一個人的失誤可以直接影響到生產環境",[11,1206,1207],{},"該問的問題：團隊寫的 Postmortem 裡，行動項目（Action Item）是「加強某某人審查」還是「補上某個自動化檢查、修正某個流程缺口」？前者是文化沒到位的訊號。",[28,1209,1211],{"id":1210},"automation自動化的順序通常應該是部署-測試-環境","Automation：自動化的順序，通常應該是「部署 → 測試 → 環境」",[11,1213,1214,1215],{},"自動化常被簡化成「把 CI\u002FCD 建起來」，但 CI\u002FCD 只是自動化裡最顯眼的一塊。更完整的問法是：",[23,1216,1217],{},"團隊裡有哪些步驟，還在依賴某個人手動執行、手動記得要做、只有他知道怎麼做？",[11,1219,1220],{},"這些步驟不分好壞，都是自動化的候選名單，常見的排序原則是先做「頻率高、風險低、規則明確」的事：",[66,1222,1223,1229,1235],{},[69,1224,1225,1228],{},[23,1226,1227],{},"部署流程","：手動 SSH 上機、手動跑腳本，是最常見也最該優先自動化的部分——風險最高、又最常做",[69,1230,1231,1234],{},[23,1232,1233],{},"測試","：自動化測試套件（Unit \u002F Integration \u002F E2E）要能在 CI 裡穩定跑完，而不是「本地測過就上」",[69,1236,1237,1240],{},[23,1238,1239],{},"環境建置","：用 IaC（Terraform、CloudFormation）取代手動在主控台點設定，讓環境本身也能重現、能被版本控制",[11,1242,1243],{},"常見誤區：自動化的目標不是「消滅人的判斷」，而是消滅重複、機械化、容易手滑的步驟，把人的判斷力留給真正需要判斷的事情（例如：這次變更要不要上、風險評估）。",[28,1245,1247],{"id":1246},"lean把還在做的浪費找出來而不是先加流程","Lean：把「還在做的浪費」找出來，而不是先加流程",[11,1249,1250],{},"Lean（精實）源自製造業的精實生產，核心精神是持續辨識並移除「浪費」（Waste）。在軟體交付的情境下，常見的浪費包括：",[66,1252,1253,1259,1265],{},[69,1254,1255,1258],{},[23,1256,1257],{},"批次過大","：累積一個月的變更才一次上線，出問題時不知道是哪個改動造成的，回滾成本也高",[69,1260,1261,1264],{},[23,1262,1263],{},"等待","：一個 PR 卡審核卡三天、一個環境申請要走簽核流程一週",[69,1266,1267,1270],{},[23,1268,1269],{},"交接損耗","：需求從 PM 傳到工程師、再傳到維運，每次交接都遺失一部分上下文，導致重工",[11,1272,1273],{},"該問的問題：從一行程式碼被寫出來，到它真正在生產環境跑起來、產生價值，中間有多少步驟是在等待，而不是在做事？精實的第一步永遠是先把整條交付流程畫出來（Value Stream Mapping），再決定要優化哪一段，而不是先加一堆新流程、新工具。",[28,1275,1277],{"id":1276},"measurement先能回答這次改動有沒有變好才能談持續改善","Measurement：先能回答「這次改動有沒有變好」，才能談持續改善",[11,1279,1280],{},"沒有量測，前面三項都無法驗證是不是真的有效——文化變好了嗎？自動化真的省下時間了嗎？精實改善真的縮短前置時間了嗎？",[11,1282,1283],{},"DevOps 圈子最常用來量測交付績效的是 DORA（DevOps Research and Assessment）四大指標：",[132,1285,1286,1296],{},[135,1287,1288],{},[138,1289,1290,1293],{},[141,1291,1292],{},"指標",[141,1294,1295],{},"衡量什麼",[152,1297,1298,1309,1320,1331],{},[138,1299,1300,1306],{},[157,1301,1302,1305],{},[23,1303,1304],{},"部署頻率","（Deployment Frequency）",[157,1307,1308],{},"多久上一次線",[138,1310,1311,1317],{},[157,1312,1313,1316],{},[23,1314,1315],{},"變更前置時間","（Lead Time for Changes）",[157,1318,1319],{},"從 commit 到上線要多久",[138,1321,1322,1328],{},[157,1323,1324,1327],{},[23,1325,1326],{},"變更失敗率","（Change Failure Rate）",[157,1329,1330],{},"上線之後導致問題的比例",[138,1332,1333,1339],{},[157,1334,1335,1338],{},[23,1336,1337],{},"服務恢復時間","（Time to Restore Service）",[157,1340,1341],{},"出問題後多久能恢復",[11,1343,1344],{},"這四個指標刻意同時看穩定性與速度——只看部署頻率會鼓勵「衝快但常常壞」，只看變更失敗率又會鼓勵「怕出事乾脆少上線」，四個一起看才不會顧此失彼。實務上不需要一次到位地精算，先能穩定拿到這四個數字的粗略趨勢，就已經比完全沒量測進步很多。",[28,1346,1348],{"id":1347},"sharing知識卡在一個人身上就是團隊最大的單點故障","Sharing：知識卡在一個人身上，就是團隊最大的單點故障",[11,1350,1351],{},"分享指的是把知識、工具、經驗跨團隊、跨角色地流通，而不是留在少數人腦中或某個沒人維護的 Wiki 裡。常見的實踐方式：",[66,1353,1354,1360,1366],{},[69,1355,1356,1359],{},[23,1357,1358],{},"內部技術分享","：把踩過的坑、解決問題的過程整理成文件或分享會，而不是解決完就結束",[69,1361,1362,1365],{},[23,1363,1364],{},"共用工具與範本","：Runbook、部署腳本、監控儀表板範本統一維護，而不是每個團隊各自重造輪子",[69,1367,1368,1371],{},[23,1369,1370],{},"輪值與結對","：讓值班（On-Call）、部署這類「只有某人會」的工作，透過輪值或結對逐漸擴散給更多人",[11,1373,1374],{},"該問的問題：如果團隊裡「懂最多」的那個人請假一週，會不會有東西因此卡住？如果答案是會，代表分享這一項還沒做到位，而這通常也是知識最集中、風險最高的地方。",[28,1376,1378],{"id":1377},"用-calms-做自我檢查而不是導入清單","用 CALMS 做自我檢查，而不是導入清單",[11,1380,1381],{},"實務上判斷團隊卡在哪個字母，可以用下面這張表快速定位：",[132,1383,1384,1394],{},[135,1385,1386],{},[138,1387,1388,1391],{},[141,1389,1390],{},"症狀",[141,1392,1393],{},"通常對應的維度",[152,1395,1396,1404,1412,1420,1428],{},[138,1397,1398,1401],{},[157,1399,1400],{},"出事就開始找戰犯，沒人敢碰核心系統",[157,1402,1403],{},"Culture",[138,1405,1406,1409],{},[157,1407,1408],{},"部署還要靠一個人手動跑腳本",[157,1410,1411],{},"Automation",[138,1413,1414,1417],{},[157,1415,1416],{},"PR 卡審核好幾天、環境申請要走簽核",[157,1418,1419],{},"Lean",[138,1421,1422,1425],{},[157,1423,1424],{},"說不出這次改動到底有沒有變好",[157,1426,1427],{},"Measurement",[138,1429,1430,1433],{},[157,1431,1432],{},"某人離職／請假，某個流程就沒人會",[157,1434,1435],{},"Sharing",[11,1437,1438],{},"CALMS 的價值不在於背下這五個字，而在於每次要導入新工具或新流程之前，先問一句：這解決的是文化、自動化、精實、量測還是分享的問題？如果答案不清楚，很可能只是在給一個沒被真正診斷出來的問題，貼上一塊新的 OK 繃。",{"title":44,"searchDepth":386,"depth":386,"links":1440},[1441,1442,1443,1444,1445,1446,1447],{"id":1176,"depth":386,"text":1177},{"id":1189,"depth":386,"text":1190},{"id":1210,"depth":386,"text":1211},{"id":1246,"depth":386,"text":1247},{"id":1276,"depth":386,"text":1277},{"id":1347,"depth":386,"text":1348},{"id":1377,"depth":386,"text":1378},"2026-09-21",{},"\u002Farticles\u002Fdevops-calms-framework",{"title":1171,"description":44},"articles\u002Fdevops-calms-framework",[1454,1455,1456,1457],"DevOps","CALMS","團隊文化","工程實踐","m0dIxAvcZ9TG7HoLq1hXbmXZgGWlpu8TgAjRg5C9Ugo",{"id":1460,"title":1461,"body":1462,"date":1904,"description":44,"excerpt":1156,"extension":1157,"meta":1905,"navigation":1035,"path":1906,"seo":1907,"stem":1908,"tags":1909,"__hash__":1912},"articles\u002Farticles\u002Fsre-slo-sli-sla-error-budget.md","SRE 的 SLI、SLO、SLA 與錯誤預算：先分清楚「量什麼」「承諾什麼」「賠什麼」",{"type":8,"value":1463,"toc":1894},[1464,1468,1471,1500,1503,1506,1510,1521,1552,1558,1561,1565,1568,1574,1577,1615,1618,1636,1643,1649,1653,1662,1665,1671,1674,1728,1732,1735,1741,1744,1748,1751,1757,1766,1769,1787,1790,1794,1797,1869,1885,1888,1891],[28,1465,1467],{"id":1466},"三個縮寫三個不同層次的問題","三個縮寫，三個不同層次的問題",[11,1469,1470],{},"SRE（Site Reliability Engineering）用 SLI、SLO、SLA 三個概念，把「服務可靠嗎」這個模糊的問題拆成三個可以分別回答的層次：",[66,1472,1473,1482,1491],{},[69,1474,1475,55,1478,1481],{},[23,1476,1477],{},"SLI（Service Level Indicator，服務水準指標）",[23,1479,1480],{},"量測什麼","——用什麼具體、可觀測的數字代表服務品質",[69,1483,1484,55,1487,1490],{},[23,1485,1486],{},"SLO（Service Level Objective，服務水準目標）",[23,1488,1489],{},"內部目標訂多少","——這個指標要維持在什麼範圍，才算「服務健康」",[69,1492,1493,55,1496,1499],{},[23,1494,1495],{},"SLA（Service Level Agreement，服務水準協議）",[23,1497,1498],{},"對外承諾、沒達成要賠什麼","——寫進合約、對客戶的正式承諾，通常附帶罰則或補償",[11,1501,1502],{},"三者是包含關係：先有 SLI 這個量尺，才能訂 SLO 這個內部目標，SLA 則通常是 SLO 裡再留一點緩衝、正式對外公布的版本。",[11,1504,1505],{},"混用最常見的錯誤，是把內部拿來自我要求的 SLO 直接當 SLA 公佈出去——SLO 是團隊用來提早自我警惕的門檻，SLA 是對外的底線，兩者刻意留有落差才有意義。",[28,1507,1509],{"id":1508},"用一個案例貫穿全文電商訂單系統的-orderservice","用一個案例貫穿全文：電商訂單系統的 OrderService",[11,1511,1512,1513,1517,1518,114],{},"為了不讓 SLI、SLO、SLA 停留在抽象定義，這篇文章統一用同一個場景舉例：",[119,1514,1516],{"href":1515},"\u002Fportfolio\u002Fecommerce-microservices-observability","電商微服務可觀測性專案"," 裡的 ",[15,1519,1520],{},"OrderService",[11,1522,1523,1524,1526,1527,1530,1531,1534,1535,1526,1537,1540,1541,1543,1544,1547,1548,1551],{},"使用者呼叫「建立訂單」API 後，",[15,1525,1520],{}," 發布 ",[15,1528,1529],{},"OrderCreatedEvent","，交給 ",[15,1532,1533],{},"PaymentService"," 請款；付款完成後 ",[15,1536,1533],{},[15,1538,1539],{},"PaymentCompletedEvent","，",[15,1542,1520],{}," 收到後確認付款（",[15,1545,1546],{},"OrderPaid","），再觸發 ",[15,1549,1550],{},"ShippingService"," 出貨。整個流程畫成圖大致是這樣：",[36,1553,1556],{"className":1554,"code":1555,"language":41},[39],"使用者\n  │\n  │ 建立訂單\n  ▼                    ← SLI 量測點：建立訂單成功且夠快的比例\nOrderService\n  │\n  │ 發布 OrderCreatedEvent\n  ▼\nPaymentService\n  │\n  │ 向金流請款\n  ▼                    ← 錯誤預算消耗點：Consumer Lag 過高，訂單卡在這步\nPaymentCompletedEvent\n  │\n  ▼\nOrderService（確認付款 OrderPaid）\n  │\n  ▼\nShippingService（觸發出貨）\n",[15,1557,1555],{"__ignoreMap":44},[11,1559,1560],{},"下面每一節都會回到這個場景，示範 SLI 怎麼選、SLO 怎麼訂、SLA 怎麼跟 SLO 拉開差距，以及錯誤預算怎麼算、怎麼用；最後一節再把這幾步收斂成一張總表。",[28,1562,1564],{"id":1563},"sli先選對量什麼量測才有意義","SLI：先選對「量什麼」，量測才有意義",[11,1566,1567],{},"SLI 是一個比率，通常長這樣：",[36,1569,1572],{"className":1570,"code":1571,"language":41},[39],"SLI = 好的事件數 \u002F 總事件數\n",[15,1573,1571],{"__ignoreMap":44},[11,1575,1576],{},"常見的 SLI 選擇，取決於服務的本質：",[132,1578,1579,1589],{},[135,1580,1581],{},[138,1582,1583,1586],{},[141,1584,1585],{},"服務類型",[141,1587,1588],{},"常見 SLI",[152,1590,1591,1599,1607],{},[138,1592,1593,1596],{},[157,1594,1595],{},"請求／回應型 API",[157,1597,1598],{},"可用性（成功回應的比例）、延遲（在 X 毫秒內回應的比例）",[138,1600,1601,1604],{},[157,1602,1603],{},"資料處理／批次系統",[157,1605,1606],{},"新鮮度（資料多久前更新）、正確性（處理結果無誤的比例）",[138,1608,1609,1612],{},[157,1610,1611],{},"儲存系統",[157,1613,1614],{},"耐久性（資料不遺失的比例）、可用性",[11,1616,1617],{},"選 SLI 最容易犯的錯，是選了系統內部方便量測、但不代表使用者真實體驗的指標。例如只看伺服器的 CPU、記憶體使用率——這些是資源指標（Resource Metric），可以拿來做容量規劃，但不能直接代表使用者感受到的服務品質。",[11,1619,1620,1621,1628,1629,1631,1632,1635],{},"SLI 應該盡量貼近使用者視角、用業務語言描述，而不是直接拿 API 路徑、HTTP 狀態碼這種技術術語當指標名稱——",[23,1622,1623,1624,1627],{},"使用者在意的是「建立訂單這件事有沒有順利完成」，不是「",[15,1625,1626],{},"\u002Forders"," 端點回不回 5xx」","。拿 ",[15,1630,1520],{}," 舉例，比較貼近使用者體驗的 SLI 會是：",[23,1633,1634],{},"過去一段時間內，「建立訂單」成功且在 300 毫秒內完成的比例","，而不是「伺服器 CPU 有沒有超過 80%」這種內部資源指標。",[11,1637,1638,1639,1642],{},"底層量測時當然還是要落到具體的 API 與狀態碼，但那是",[23,1640,1641],{},"實作細節","，不是對外溝通這個 SLI 時該用的語言：",[36,1644,1647],{"className":1645,"code":1646,"language":41},[39],"# 業務指標：建立訂單成功且在 300ms 內完成的比例\nsum(rate(http_requests_total{service=\"OrderService\", route=\"\u002Forders\", status!~\"5..\", le=\"300ms\"}[28d]))\n\u002F\nsum(rate(http_requests_total{service=\"OrderService\", route=\"\u002Forders\"}[28d]))\n",[15,1648,1646],{"__ignoreMap":44},[28,1650,1652],{"id":1651},"slo內部目標用來在還沒出事的時候就先警覺","SLO：內部目標，用來在「還沒出事」的時候就先警覺",[11,1654,1655,1656,1658,1659,114],{},"SLO 是針對 SLI 訂出的目標範圍，通常寫成「在 N 天的滾動視窗內，SLI 要維持在 X% 以上」。針對前面的 ",[15,1657,1520],{}," 建立訂單 API，一個合理的內部 SLO 可能是：",[23,1660,1661],{},"過去 30 天，可用性 ≥ 99.9%",[11,1663,1664],{},"SLO 不是訂得越高越好。100% 的可靠性幾乎不可能達成，而且追求它的邊際成本極高——從 99.9% 提升到 99.99%，代表允許的故障時間從一年 8.76 小時降到 52.6 分鐘，這通常需要多活架構、更複雜的容錯機制，成本可能是等比級數上升，但使用者實際感受到的差異可能很小。",[11,1666,1667,1668,1670],{},"訂 SLO 時該問的問題是：使用者真的會在意這個差異嗎？多花的成本，值得換來這個差異嗎？對 ",[15,1669,1520],{}," 來說，建立訂單偶爾多等幾百毫秒，使用者多半無感；但如果整條「建立訂單 → 確認付款 → 觸發出貨」的事件鏈卡住超過幾分鐘，使用者就會直接看到訂單狀態卡住不動，這種等級的落差，才是決定 SLO 該訂多嚴格的依據。",[11,1672,1673],{},"常見的可用性 SLO 與對應的容忍停機時間（一年）：",[132,1675,1676,1686],{},[135,1677,1678],{},[138,1679,1680,1683],{},[141,1681,1682],{},"SLO",[141,1684,1685],{},"一年可容忍的停機時間",[152,1687,1688,1696,1704,1712,1720],{},[138,1689,1690,1693],{},[157,1691,1692],{},"99%",[157,1694,1695],{},"約 3.65 天",[138,1697,1698,1701],{},[157,1699,1700],{},"99.9%",[157,1702,1703],{},"約 8.76 小時",[138,1705,1706,1709],{},[157,1707,1708],{},"99.95%",[157,1710,1711],{},"約 4.38 小時",[138,1713,1714,1717],{},[157,1715,1716],{},"99.99%",[157,1718,1719],{},"約 52.6 分鐘",[138,1721,1722,1725],{},[157,1723,1724],{},"99.999%",[157,1726,1727],{},"約 5.26 分鐘",[28,1729,1731],{"id":1730},"sla對外承諾通常比-slo-更寬鬆","SLA：對外承諾，通常比 SLO 更寬鬆",[11,1733,1734],{},"SLA 是寫進合約、對客戶的正式承諾，沒達成通常伴隨罰則（例如：退費、抵用額度）。因為違反 SLA 有實際代價，SLA 的門檻通常會設得比內部 SLO 更寬鬆。",[11,1736,1737,1738,1740],{},"如果 ",[15,1739,1520],{}," 未來要包裝成對外銷售的電商 SaaS，內部 SLO 可以訂 99.95%，但寫進客戶合約的 SLA 通常會退一步只承諾 99.9%——中間留的差距，就是給團隊在真正踩到「要賠錢」的紅線之前，有機會先自己發現、先自己修正的緩衝空間。",[11,1742,1743],{},"如果把 SLO 直接當 SLA 公佈，等於把「自我警惕的門檻」和「賠錢的底線」訂成同一條線，團隊會失去這段緩衝，每次 SLO 稍微沒達標就等於要面對合約糾紛，這通常會逼團隊在訂 SLO 時反而不敢訂高，長期反而傷害真正的可靠性文化。",[28,1745,1747],{"id":1746},"錯誤預算把可靠性換算成一個可以花的額度","錯誤預算：把「可靠性」換算成一個可以花的額度",[11,1749,1750],{},"錯誤預算（Error Budget）是 SRE 最核心的工具，公式很直接：",[36,1752,1755],{"className":1753,"code":1754,"language":41},[39],"錯誤預算 = 1 - SLO\n",[15,1756,1754],{"__ignoreMap":44},[11,1758,1737,1759,1761,1762,1765],{},[15,1760,1520],{}," 建立訂單 API 的 SLO 是 99.9%，錯誤預算就是 0.1%——代表在 30 天的視窗週期內，允許不可用（或不符合 SLI 定義的「好」）的時間大約是 ",[23,1763,1764],{},"43.2 分鐘","，這是團隊在這個週期內可以「花」的額度。",[11,1767,1768],{},"錯誤預算真正厲害的地方，是它把「可靠性」跟「開發速度」放進同一個可以量化的天平上，變成團隊自己可以決定怎麼分配的資源，而不是模糊的「盡量別出事」：",[66,1770,1771,1777],{},[69,1772,1773,1776],{},[23,1774,1775],{},"預算還沒花完","：代表系統目前比承諾的更可靠，團隊可以承擔更高風險——加快發版節奏、上更多實驗性功能、做更積極的架構重構",[69,1778,1779,1782,1783,1786],{},[23,1780,1781],{},"預算已經花完（或快花完）","：代表可靠性已經逼近甚至跌破承諾的底線，這時候應該",[23,1784,1785],{},"凍結非必要的變更","，把資源轉去修復可靠性問題（減少故障、改善監控、補測試），直到預算恢復到安全水位",[11,1788,1789],{},"這個機制解決了 SRE 團隊與產品／開發團隊之間最常見的拉鋸：開發團隊想衝功能上線速度，SRE 想要穩定，雙方常常各說各話。錯誤預算把這個拉鋸變成一條雙方都同意、有數字依據的規則——預算沒花完就能衝，花完了就該停下來修穩定性，不需要每次都吵一輪「這樣改風險大不大」。",[28,1791,1793],{"id":1792},"三者放在一起的實務流程走一遍-orderservice-的完整情境","三者放在一起的實務流程：走一遍 OrderService 的完整情境",[11,1795,1796],{},"把前面幾節串起來，套進「建立訂單」這件事，四個概念實際會填成這樣的具體範例：",[132,1798,1799,1812],{},[135,1800,1801],{},[138,1802,1803,1806,1809],{},[141,1804,1805],{},"概念",[141,1807,1808],{},"決定什麼",[141,1810,1811],{},"「建立訂單」的範例",[152,1813,1814,1827,1839,1856],{},[138,1815,1816,1821,1824],{},[157,1817,1818],{},[23,1819,1820],{},"SLI",[157,1822,1823],{},"量什麼",[157,1825,1826],{},"建立訂單成功且在 300ms 內完成的比例",[138,1828,1829,1833,1836],{},[157,1830,1831],{},[23,1832,1682],{},[157,1834,1835],{},"內部目標訂多嚴格",[157,1837,1838],{},"過去 30 天，建立訂單成功的比例 ≥ 99.9%",[138,1840,1841,1846,1849],{},[157,1842,1843],{},[23,1844,1845],{},"Error Budget",[157,1847,1848],{},"這個週期可以花多少不可用時間",[157,1850,1851,1852,1855],{},"30 天內，建立訂單最多可以累計 43.2 分鐘不可用（",[15,1853,1854],{},"1 - 99.9%"," 換算）",[138,1857,1858,1863,1866],{},[157,1859,1860],{},[23,1861,1862],{},"SLA",[157,1864,1865],{},"對外承諾、賠什麼",[157,1867,1868],{},"對客戶承諾建立訂單成功率 ≥ 99.5%，達不到就退費或補償",[11,1870,1871,1874,1875,1877,1878,1881,1882,1884],{},[23,1872,1873],{},"實際決策範例","：某天 ",[15,1876,1533],{}," 因為 Kafka Consumer Lag 飆高，大量訂單卡在「已建立、但付款一直沒被確認」，事後盤點這次事件吃掉了 30 分鐘的錯誤預算，只剩 13.2 分鐘餘裕。團隊據此決定這個月",[23,1879,1880],{},"暫停","訂閱制、優惠券等新功能開發，優先修 ",[15,1883,1533],{}," 的消費延遲、補上 Consumer Lag 告警，等下個 30 天視窗重新開始、預算恢復安全水位，才恢復原本的功能開發步調。",[11,1886,1887],{},"這張表就是 SLI、SLO、SLA、錯誤預算實際搭配運作的樣子：SLI 決定量什麼，SLO 決定內部多嚴格，SLA 決定對外承諾多少，錯誤預算則把前三者換算成一個具體數字，讓「現在該衝新功能還是該停下來修穩定性」變成有數字依據的決策，而不是靠感覺吵架。",[28,1889,1890],{"id":1890},"一句話總結",[11,1892,1893],{},"SLI 決定量什麼，SLO 決定內部目標訂多嚴格，SLA 決定對外承諾與賠什麼；錯誤預算則是把「可靠性」換算成一個團隊可以自己決定怎麼花的額度——預算還在，就有本錢衝新功能；預算花完，就該停下來把穩定性補回來。",{"title":44,"searchDepth":386,"depth":386,"links":1895},[1896,1897,1898,1899,1900,1901,1902,1903],{"id":1466,"depth":386,"text":1467},{"id":1508,"depth":386,"text":1509},{"id":1563,"depth":386,"text":1564},{"id":1651,"depth":386,"text":1652},{"id":1730,"depth":386,"text":1731},{"id":1746,"depth":386,"text":1747},{"id":1792,"depth":386,"text":1793},{"id":1890,"depth":386,"text":1890},"2026-09-20",{},"\u002Farticles\u002Fsre-slo-sli-sla-error-budget",{"title":1461,"description":44},"articles\u002Fsre-slo-sli-sla-error-budget",[1166,1682,1820,1862,1910,1911],"錯誤預算","可靠性","lqwaaMIs2KkvlJVPpDiWDS6HlFHFdbAdfvCtkF9tfyY",{"id":1914,"title":1915,"body":1916,"date":2295,"description":44,"excerpt":1156,"extension":1157,"meta":2296,"navigation":1035,"path":2297,"seo":2298,"stem":2299,"tags":2300,"__hash__":2302},"articles\u002Farticles\u002Fblue-green-canary-feature-toggle.md","藍綠部署、金絲雀部署、Feature Toggle：三種降低上線風險的部署機制",{"type":8,"value":1917,"toc":2286},[1918,1921,1928,1935,1939,1949,1956,1961,1976,1981,2007,2011,2016,2022,2026,2041,2045,2071,2075,2080,2086,2089,2115,2119,2130,2134,2164,2168,2171,2233,2237,2247,2250,2270,2281,2283],[28,1919,1920],{"id":1920},"三者要解決的共同問題",[11,1922,1923,1924,1927],{},"傳統的部署方式是把舊版本整個停掉、換上新版本，中間有一段停機時間，而且新版本一上線就要面對 100% 的流量——如果新版本有問題，影響的是",[23,1925,1926],{},"所有使用者","，發現問題後也只能匆忙回滾。",[11,1929,1930,1931,1934],{},"藍綠部署、金絲雀部署、Feature Toggle 都是想解決同一件事：",[23,1932,1933],{},"把「上線」這個高風險動作，拆成更小、更可控、可以隨時喊停的步驟","，差別在於它們各自在哪一層做這件事——藍綠部署換的是整套執行環境，金絲雀部署換的是流量比例，Feature Toggle 換的是應用程式裡的一段邏輯開關。",[28,1936,1938],{"id":1937},"藍綠部署blue-green-deployment","藍綠部署（Blue-Green Deployment）",[11,1940,1941,1944,1945,1948],{},[23,1942,1943],{},"運作方式","：準備兩套一模一樣的正式環境，一套叫 Blue（目前正在服務的版本），一套叫 Green（要上線的新版本）。新版本先完整部署到 Green、做健康檢查與暖機，確認沒問題後，透過負載平衡器把流量",[23,1946,1947],{},"整批切換","到 Green；如果切換後發現問題，只要把路由切回 Blue 就能立即恢復。",[11,1950,1951],{},[1952,1953],"img",{"alt":1954,"src":1955},"藍綠部署示意圖：負載平衡器把流量整批指向 Blue 或 Green 其中一套環境","\u002Fimages\u002Farticles\u002Fblue-green-deployment.svg",[11,1957,1958,55],{},[23,1959,1960],{},"適合的情境",[66,1962,1963,1966,1969],{},[69,1964,1965],{},"想要幾乎零停機時間的上線方式——切換是路由層面的操作，不是重啟服務",[69,1967,1968],{},"想要「出事能立刻整批回滾」，而不是重新部署舊版本",[69,1970,1971,1972,1975],{},"新舊版本之間",[23,1973,1974],{},"不需要同時服務流量","、資料相容性問題相對單純的情境",[11,1977,1978,55],{},[23,1979,1980],{},"使用風險",[66,1982,1983,1989,1995,2001],{},[69,1984,1985,1988],{},[23,1986,1987],{},"雙倍資源成本","：兩套環境要同時常駐（至少在切換過渡期），資源花費接近兩倍",[69,1990,1991,1994],{},[23,1992,1993],{},"全有或全無，沒有漸進空間","：切換是瞬間的，如果 Green 有邏輯性錯誤但沒被健康檢查攔下來，切換的當下就是 100% 的使用者受影響，沒有「先讓一小部分人試試看」的餘地",[69,1996,1997,2000],{},[23,1998,1999],{},"資料庫相容性最麻煩","：如果 Blue 跟 Green 共用同一個資料庫，切換瞬間、甚至切回去的那一刻，新舊版本都可能對同一份資料做讀寫，資料庫的 schema 變更必須同時讓新舊版本都能運作",[69,2002,2003,2006],{},[23,2004,2005],{},"有狀態連線的處理","：像 WebSocket 這種長連線，整批切換會讓連線瞬間中斷，需要額外設計優雅排空（Drain）或重連機制",[28,2008,2010],{"id":2009},"金絲雀部署canary-deployment","金絲雀部署（Canary Deployment）",[11,2012,2013,2015],{},[23,2014,1943],{},"：新版本先只導入一小部分流量（例如 5%），跟舊版本同時上線、同時服務使用者，透過監控指標觀察新版本是否正常；指標健康就逐步擴大流量比例（5% → 25% → 50% → 100%），指標異常就立即把金絲雀流量退回 0%，全程只有一小部分使用者受到影響。",[11,2017,2018],{},[1952,2019],{"alt":2020,"src":2021},"金絲雀部署示意圖：負載平衡器依比例把流量分給舊版本與金絲雀新版本，指標正常逐步擴大比例","\u002Fimages\u002Farticles\u002Fcanary-deployment.svg",[11,2023,2024,55],{},[23,2025,1960],{},[66,2027,2028,2035,2038],{},[69,2029,2030,2031,2034],{},"想要用",[23,2032,2033],{},"真實流量","驗證新版本，而不是只靠測試環境或人工驗收",[69,2036,2037],{},"使用者基數夠大，切出 5% 的流量仍然是有代表性的樣本",[69,2039,2040],{},"團隊已經有一定的可觀測性基礎（延遲、錯誤率等關鍵指標的監控），能在流量還很小的時候就判斷「這個版本健不健康」——如果團隊已經定義了 SLO，金絲雀期間消耗掉的錯誤預算，本身就是一個很自然的自動回滾依據",[11,2042,2043,55],{},[23,2044,1980],{},[66,2046,2047,2053,2059,2065],{},[69,2048,2049,2052],{},[23,2050,2051],{},"新舊版本必須同時服務、同時讀寫同一份資料","：這比藍綠部署的相容性要求更長時間——只要金絲雀流量還沒到 100%，新舊版本就會同時存在一段時間，新版本寫入的資料格式，舊版本（還在服務剩餘流量）也必須看得懂，反之亦然",[69,2054,2055,2058],{},[23,2056,2057],{},"流量分配要有代表性","：如果分流方式設計不好（例如剛好把同一個地區、同一種裝置的使用者導進金絲雀），5% 的樣本可能無法代表真實的流量特性，觀察到的指標就沒有意義",[69,2060,2061,2064],{},[23,2062,2063],{},"需要自動化的判斷與回滾機制","：金絲雀的意義在於「小範圍先試」，如果沒有自動化告警、自動回滾，全靠人工盯著儀表板看，實際上就退化成手動、慢半拍的藍綠切換",[69,2066,2067,2070],{},[23,2068,2069],{},"路由層要求更高","：需要能依比例動態分流的基礎設施（Kubernetes 搭配 Service Mesh、或雲端負載平衡器的加權路由），比藍綠部署單純的「切全部」複雜",[28,2072,2074],{"id":2073},"feature-toggle功能開關","Feature Toggle（功能開關）",[11,2076,2077,2079],{},[23,2078,1943],{},"：在程式碼裡用一個開關（Flag）包住新功能，讓「部署」跟「發布」變成兩件互相獨立的事——新程式碼可以先完整部署上線，但功能保持關閉狀態，之後只要透過設定或後台切換開關，就能立即讓功能生效，不需要重新部署。",[11,2081,2082],{},[1952,2083],{"alt":2084,"src":2085},"Feature Toggle 示意圖：同一個應用程式版本內部依 Feature Flag 狀態走不同邏輯分支","\u002Fimages\u002Farticles\u002Ffeature-toggle.svg",[11,2087,2088],{},"Feature Toggle 常見可以再分幾種用途：",[66,2090,2091,2097,2103,2109],{},[69,2092,2093,2096],{},[23,2094,2095],{},"Release Toggle（釋出開關）","：短期使用，功能穩定全開之後就該把開關和舊邏輯一起移除",[69,2098,2099,2102],{},[23,2100,2101],{},"Experiment Toggle（實驗開關）","：用來做 A\u002FB 測試，不同使用者群組看到不同邏輯",[69,2104,2105,2108],{},[23,2106,2107],{},"Ops Toggle（維運開關）","：緊急時可以手動關閉某個功能或繞過某個故障中的外部依賴，降低系統負載或止血",[69,2110,2111,2114],{},[23,2112,2113],{},"Permission Toggle（權限開關）","：依角色或訂閱方案決定功能是否可見，本質上是長期存在的權限控制，不是臨時性的釋出開關",[11,2116,2117,55],{},[23,2118,1960],{},[66,2120,2121,2124,2127],{},[69,2122,2123],{},"想要把「部署」的風險跟「對使用者開放功能」的風險分開處理，兩者用不同的節奏推進",[69,2125,2126],{},"功能要不要開放，需要由業務端自主決定時機（例如配合行銷活動時間點），而不是綁死在部署排程上",[69,2128,2129],{},"需要對特定使用者群組（VIP、內部員工、特定地區）先行開放做驗證",[11,2131,2132,55],{},[23,2133,1980],{},[66,2135,2136,2146,2152,2158],{},[69,2137,2138,2141,2142,2145],{},[23,2139,2140],{},"開關地獄（Flag Hell）","：Release Toggle 如果沒有在功能穩定後清理掉，程式碼裡會累積大量 ",[15,2143,2144],{},"if (flag)"," 分支，多個開關交叉組合時，程式碼的實際執行路徑數量會指數增長，變得極難測試、極難理解",[69,2147,2148,2151],{},[23,2149,2150],{},"測試矩陣爆炸","：QA 理論上要覆蓋每個開關「開／關」兩種狀態的組合，開關一多，完整測試所有組合在實務上幾乎不可能，通常只能挑重點組合驗證",[69,2153,2154,2157],{},[23,2155,2156],{},"開關依賴本身是新的故障點","：如果開關狀態是從遠端的 Feature Flag 服務即時查詢，那個服務本身的可用性、延遲，都會直接影響應用程式；務必設計好查詢失敗時的預設值（Fail Open 或 Fail Closed 要依功能性質決定），不然開關系統本身變成單點故障",[69,2159,2160,2163],{},[23,2161,2162],{},"同樣有相容性問題","：如果開關控制的功能牽涉資料格式（例如新邏輯寫入新欄位），開關關閉時期寫入的舊格式資料，開啟後的新邏輯要能正確處理，這跟藍綠、金絲雀面對的是同一類問題，只是換了個地方發生",[28,2165,2167],{"id":2166},"三者可以疊加使用不是互斥的選擇","三者可以疊加使用，不是互斥的選擇",[11,2169,2170],{},"這三個機制作用的層次不同，實務上經常疊加使用：先用金絲雀部署讓新版本的程式碼只接收一小部分流量、驗證程式本身沒有崩潰或效能問題；與此同時，程式碼裡新功能的 Feature Toggle 仍然保持關閉，等到金絲雀擴大到 100%、確認新版本本身穩定之後，才透過 Feature Toggle 真正對使用者開放這個功能。「部署」跟「發布」在這個流程裡被完全拆開成兩個獨立的決策點。",[132,2172,2173,2189],{},[135,2174,2175],{},[138,2176,2177,2180,2183,2186],{},[141,2178,2179],{},"機制",[141,2181,2182],{},"切換的對象",[141,2184,2185],{},"粒度",[141,2187,2188],{},"回滾速度",[152,2190,2191,2205,2219],{},[138,2192,2193,2196,2199,2202],{},[157,2194,2195],{},"藍綠部署",[157,2197,2198],{},"整套執行環境",[157,2200,2201],{},"環境層級（全有或全無）",[157,2203,2204],{},"快（路由切回），但影響面是全量",[138,2206,2207,2210,2213,2216],{},[157,2208,2209],{},"金絲雀部署",[157,2211,2212],{},"流量比例",[157,2214,2215],{},"流量層級（可微調百分比）",[157,2217,2218],{},"快，且只影響金絲雀那一部分流量",[138,2220,2221,2224,2227,2230],{},[157,2222,2223],{},"Feature Toggle",[157,2225,2226],{},"程式邏輯分支",[157,2228,2229],{},"功能層級（可依使用者群組）",[157,2231,2232],{},"最快（改設定，不必重新部署）",[28,2234,2236],{"id":2235},"共同的核心風險向下相容","共同的核心風險：向下相容",[11,2238,2239,2240,2243,2244,114],{},"三個機制都建立在同一個前提上：",[23,2241,2242],{},"新舊版本（或新舊資料格式）必須在同一段時間內同時存在、同時被使用","。藍綠部署的重疊時間最短（切換前後那一刻），金絲雀部署的重疊時間較長（整個逐步放量的過程），Feature Toggle 的重疊時間可能最長（開關關閉的整段期間），但本質上都要面對同一個問題：",[23,2245,2246],{},"向下相容（Backward Compatibility）",[11,2248,2249],{},"資料庫層面常見的做法是「擴張—收縮模式」（Expand-Contract Pattern）：",[211,2251,2252,2258,2264],{},[69,2253,2254,2257],{},[23,2255,2256],{},"擴張","：新增欄位或新增表格，但不刪除、不修改舊有結構，讓舊版本完全不受影響地繼續運作",[69,2259,2260,2263],{},[23,2261,2262],{},"雙寫過渡","：新版本上線期間，視情況同時寫入新舊兩種格式，確保無論流量落在新版本或舊版本，資料都是完整、一致的",[69,2265,2266,2269],{},[23,2267,2268],{},"收縮","：等所有流量都已經切到新版本、確認沒有回滾的需求之後，才移除舊欄位或舊表格",[11,2271,2272,2273,2276,2277,2280],{},"API 層面的原則相同：新版本的 API 要嘛保持跟舊版本相容，要嘛做好版本控制（同時提供 ",[15,2274,2275],{},"\u002Fv1","、",[15,2278,2279],{},"\u002Fv2","），不能讓還沒升級完的呼叫方，因為後端搶先升級就直接壞掉。",[28,2282,1890],{"id":1890},[11,2284,2285],{},"藍綠部署換的是整套環境、金絲雀部署換的是流量比例、Feature Toggle 換的是程式邏輯分支，三者可以疊加使用、各自處理不同粒度的上線風險；但不管用哪一種，只要新舊版本會同時存在，向下相容就是繞不過去的前提，資料庫與 API 的變更都該用「先擴張、再收縮」的方式，讓舊版本在切換的過程中不會被突然打壞。",{"title":44,"searchDepth":386,"depth":386,"links":2287},[2288,2289,2290,2291,2292,2293,2294],{"id":1920,"depth":386,"text":1920},{"id":1937,"depth":386,"text":1938},{"id":2009,"depth":386,"text":2010},{"id":2073,"depth":386,"text":2074},{"id":2166,"depth":386,"text":2167},{"id":2235,"depth":386,"text":2236},{"id":1890,"depth":386,"text":1890},"2026-06-11",{},"\u002Farticles\u002Fblue-green-canary-feature-toggle",{"title":1915,"description":44},"articles\u002Fblue-green-canary-feature-toggle",[2301,1454,2195,2209,2223,1166],"部署策略","w1ve6NLczHCmkxTQPML44nVp1fkbtFB2iBdbkKpauys",{"id":2304,"title":2305,"body":2306,"date":2750,"description":44,"excerpt":1156,"extension":1157,"meta":2751,"navigation":1035,"path":2752,"seo":2753,"stem":2754,"tags":2755,"__hash__":2759},"articles\u002Farticles\u002Fprometheus-metric-types.md","Prometheus 指標類型怎麼選？從業務情境反推該用 Counter、Gauge 還是 Histogram",{"type":8,"value":2307,"toc":2732},[2308,2312,2327,2333,2337,2341,2351,2357,2362,2373,2395,2399,2406,2410,2421,2427,2434,2438,2445,2465,2472,2478,2482,2493,2503,2507,2518,2527,2530,2533,2625,2628,2632,2643,2647,2661,2667,2670,2674,2701,2705,2712,2716,2723,2726],[28,2309,2311],{"id":2310},"為什麼選對指標類型比會寫-promql更重要","為什麼「選對指標類型」比「會寫 PromQL」更重要",[11,2313,2314,2315,2318,2319,2322,2323,2326],{},"Prometheus 的指標類型只有四種：",[23,2316,2317],{},"Counter、Gauge、Histogram、Summary","。但實\n務上最常見的問題不是團隊不知道這四種類型的定義，而是選錯類型之後，資料",[23,2320,2321],{},"已經\n沒辦法回頭","——Counter 存成 Gauge，你就再也算不出精確的速率；用 Gauge 存百分\n比，你就再也沒辦法用不同時間窗口重新計算比率。選型是在",[23,2324,2325],{},"寫程式碼埋點的當\n下","就決定的，出了問題往往要等到出事故、要看儀表板時才發現，那時候通常已經跑\n了好幾個月的正式環境資料。",[11,2328,2329,2330,114],{},"這篇文章的目標不是重講一次官方文件的定義，而是給一個更好用的思考順序：",[23,2331,2332],{},"先\n問這是什麼樣的業務情境，再回推該用哪種指標類型",[28,2334,2336],{"id":2335},"四種指標類型各自解決什麼問題","四種指標類型，各自解決什麼問題",[100,2338,2340],{"id":2339},"counter只增不減的累計數","Counter：只增不減的累計數",[11,2342,2343,2344,2347,2348,114],{},"Counter 是一個",[23,2345,2346],{},"只會增加","的數字（服務重啟歸零除外）。它本身的絕對值通常沒有\n意義，真正有用的是",[23,2349,2350],{},"拿它算一段時間內的變化率",[36,2352,2355],{"className":2353,"code":2354,"language":41},[39],"# 每秒訂單建立速率（過去 5 分鐘平均）\nrate(orders_created_total[5m])\n",[15,2356,2354],{"__ignoreMap":44},[11,2358,2359,55],{},[23,2360,2361],{},"適合的業務情境",[66,2363,2364,2367,2370],{},[69,2365,2366],{},"訂單建立數、付款請求數、訊息發送數——任何「累計發生了幾次」的事件",[69,2368,2369],{},"API 請求數、依 status code 拆分的錯誤數",[69,2371,2372],{},"Kafka 訊息生產\u002F消費數量",[11,2374,2375,2378,2379,2382,2383,2386,2387,2390,2391,2394],{},[23,2376,2377],{},"命名慣例","：以 ",[15,2380,2381],{},"_total"," 結尾（如 ",[15,2384,2385],{},"orders_created_total","），讓其他人一看名\n字就知道這是 Counter，要搭配 ",[15,2388,2389],{},"rate()"," 或 ",[15,2392,2393],{},"increase()"," 使用，而不是直接看原始\n數值。",[100,2396,2398],{"id":2397},"gauge可增可減的即時快照","Gauge：可增可減的即時快照",[11,2400,2401,2402,2405],{},"Gauge 是",[23,2403,2404],{},"任何時刻都可能上升或下降","的數值，代表「現在的狀態」而不是「累計發\n生過幾次」。",[11,2407,2408,55],{},[23,2409,2361],{},[66,2411,2412,2415,2418],{},[69,2413,2414],{},"目前線上使用者數、進行中的訂單數、待出貨物件數",[69,2416,2417],{},"佇列長度、Kafka Consumer Lag、連線池使用中的連線數",[69,2419,2420],{},"Kubernetes Pod 數量、記憶體使用量、CPU 使用率",[36,2422,2425],{"className":2423,"code":2424,"language":41},[39],"# 目前待處理付款數超過 100 筆就要警戒\npayments_pending > 100\n",[15,2426,2424],{"__ignoreMap":44},[11,2428,2429,2430,2433],{},"Gauge 最大的陷阱是",[23,2431,2432],{},"把它拿來存「速率」或「比率」","——這兩種東西都應該從\nCounter 算出來，而不是另外開一個 Gauge 直接寫死當下算出來的數字（原因在下面\n「常見地雷」細講）。",[100,2435,2437],{"id":2436},"histogram把觀測值分布到預先定義的桶換取可聚合的分位數","Histogram：把觀測值分布到預先定義的桶，換取可聚合的分位數",[11,2439,2440,2441,2444],{},"Histogram 用來觀察一批數值的",[23,2442,2443],{},"分布狀況","，例如 API 回應時間、訂單金額。它會\n把每一次觀測值分配進預先定義好的桶（bucket），並且自動產生三種時間序列：",[66,2446,2447,2453,2459],{},[69,2448,2449,2452],{},[15,2450,2451],{},"xxx_bucket{le=\"0.5\"}","：小於等於 0.5 秒的累計次數",[69,2454,2455,2458],{},[15,2456,2457],{},"xxx_sum","：所有觀測值的總和",[69,2460,2461,2464],{},[15,2462,2463],{},"xxx_count","：觀測次數",[11,2466,2467,2468,2471],{},"查詢時用 ",[15,2469,2470],{},"histogram_quantile()"," 在 PromQL 層級動態算出任意分位數：",[36,2473,2476],{"className":2474,"code":2475,"language":41},[39],"# 過去 5 分鐘的 API 回應時間 p95\nhistogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))\n",[15,2477,2475],{"__ignoreMap":44},[11,2479,2480,55],{},[23,2481,2361],{},[66,2483,2484,2487,2490],{},[69,2485,2486],{},"API／RPC 回應時間，搭配 SLO 監控 p95／p99",[69,2488,2489],{},"訂單金額分布、批次任務處理時間、檔案大小分布",[69,2491,2492],{},"任何你會想問「95% 的使用者體驗大概落在什麼範圍」的數值",[11,2494,2495,2496,2499,2500,114],{},"Histogram 的分位數是",[23,2497,2498],{},"由 bucket 邊界估算出來的","，桶切得越貼近實際分布，估\n算越準；桶切得太粗，p99 可能失真。這也是選這個類型時真正要花心思的地方，不是\n選不選，而是",[23,2501,2502],{},"桶要怎麼切",[100,2504,2506],{"id":2505},"summary用戶端直接算好分位數但無法跨實例聚合","Summary：用戶端直接算好分位數，但無法跨實例聚合",[11,2508,2509,2510,2513,2514,2517],{},"Summary 表面上跟 Histogram 很像，也是觀察一批數值的分布，但它是在",[23,2511,2512],{},"應用程式\n本地","直接計算好分位數再送出去，而不是送出桶再讓伺服器端算。這帶來一個致命限\n制：",[23,2515,2516],{},"多個副本（replica）的 Summary 分位數沒辦法正確合併","——你沒辦法把三個\nPod 各自算出的 p95 平均起來得到整體的 p95，那在數學上是錯的。",[11,2519,2520,2523,2524,114],{},[23,2521,2522],{},"什麼時候還適合用 Summary","：只有在你確定「不需要跨實例聚合」、且應用程式本\n地算分位數的成本可以接受的情境，例如單一長駐行程內部的自我監控。在 Kubernetes\n這種天生多副本的環境下，",[23,2525,2526],{},"預設應該選 Histogram，而不是 Summary",[28,2528,2529],{"id":2529},"從業務情境反推該用哪種類型",[11,2531,2532],{},"實務上更好用的思考方式是反過來問：這個業務情境的本質是「累計次數」「即時狀\n態」還是「一批數值的分布」。",[132,2534,2535,2548],{},[135,2536,2537],{},[138,2538,2539,2542,2545],{},[141,2540,2541],{},"業務情境",[141,2543,2544],{},"該問的問題",[141,2546,2547],{},"對應類型",[152,2549,2550,2561,2572,2583,2593,2604,2615],{},[138,2551,2552,2555,2558],{},[157,2553,2554],{},"訂單建立、付款請求、訊息發送",[157,2556,2557],{},"這件事發生了幾次？",[157,2559,2560],{},"Counter",[138,2562,2563,2566,2569],{},[157,2564,2565],{},"目前待出貨訂單數、線上人數",[157,2567,2568],{},"現在的狀態是多少？",[157,2570,2571],{},"Gauge",[138,2573,2574,2577,2580],{},[157,2575,2576],{},"API 回應時間、訂單金額分布",[157,2578,2579],{},"這批數值的分布長怎樣？p95 是多少？",[157,2581,2582],{},"Histogram",[138,2584,2585,2588,2591],{},[157,2586,2587],{},"Kafka Consumer Lag",[157,2589,2590],{},"現在落後多少（會漲會跌）？",[157,2592,2571],{},[138,2594,2595,2598,2601],{},[157,2596,2597],{},"付款成功率",[157,2599,2600],{},"一段時間內，成功／總數的比率？",[157,2602,2603],{},"兩個 Counter 相除（見下）",[138,2605,2606,2609,2612],{},[157,2607,2608],{},"快取命中率",[157,2610,2611],{},"命中／總請求數的比率？",[157,2613,2614],{},"帶 label 的 Counter 相除",[138,2616,2617,2620,2623],{},[157,2618,2619],{},"資料庫連線池使用數",[157,2621,2622],{},"現在用了幾條連線？",[157,2624,2571],{},[28,2626,2627],{"id":2627},"常見地雷",[100,2629,2631],{"id":2630},"_1-用-gauge-存每秒請求數","1. 用 Gauge 存「每秒請求數」",[11,2633,2634,2635,2638,2639,2642],{},"看起來很直覺：應用程式自己算好 QPS，寫進一個 Gauge。問題是這個數字",[23,2636,2637],{},"取決於你\n多久算一次、用什麼視窗算","，換一個監控週期結果就不一樣，而且沒辦法事後用不同\n時間窗口重新計算。正確做法：用 Counter 記錄累計請求數，查詢時用\n",[15,2640,2641],{},"rate(requests_total[1m])","，時間窗口可以在查詢當下自由調整。",[100,2644,2646],{"id":2645},"_2-把比率百分比直接存成-gauge","2. 把比率／百分比直接存成 Gauge",[11,2648,2649,2650,2653,2654,2276,2657,2660],{},"「付款成功率」「快取命中率」這類比率，不要在應用程式裡自己算好百分比再存成\nGauge。正確做法是存",[23,2651,2652],{},"分子與分母兩個 Counter","（如\n",[15,2655,2656],{},"payments_success_total",[15,2658,2659],{},"payments_attempted_total","），比率交給 PromQL 算：",[36,2662,2665],{"className":2663,"code":2664,"language":41},[39],"rate(payments_success_total[5m]) \u002F rate(payments_attempted_total[5m])\n",[15,2666,2664],{"__ignoreMap":44},[11,2668,2669],{},"這樣你隨時可以改變時間窗口重新看比率的變化趨勢，存死的百分比 Gauge 做不到這\n件事。",[100,2671,2673],{"id":2672},"_3-用高基數high-cardinality欄位當-label","3. 用高基數（High Cardinality）欄位當 Label",[11,2675,2676,2677,2276,2680,2276,2683,2686,2687,2690,2691,2276,2694,2276,2697,2700],{},"把 ",[15,2678,2679],{},"user_id",[15,2681,2682],{},"order_id",[15,2684,2685],{},"email"," 這種幾乎唯一的值放進 Label，會讓 Prometheus\n要為每一種組合維護一條獨立的時間序列，資料量瞬間爆炸，嚴重時直接拖垮\nPrometheus 本身。Label 應該用來描述",[23,2688,2689],{},"有限集合","的維度，例如\n",[15,2692,2693],{},"status_code",[15,2695,2696],{},"payment_method",[15,2698,2699],{},"service_name","，而不是任何形式的唯一識別\n碼。",[100,2702,2704],{"id":2703},"_4-histogram-的-bucket-邊界照抄範例沒有貼合實際分布","4. Histogram 的 bucket 邊界照抄範例，沒有貼合實際分布",[11,2706,2707,2708,2711],{},"Prometheus 官方範例的預設 bucket（如 ",[15,2709,2710],{},"0.005, 0.01, 0.025, 0.05, ...","）是為\n一般 Web API 設計的。如果你的服務正常回應時間是 200ms～2s，用預設 bucket\n會讓大部分請求都落在同一個桶裡，算出來的 p95／p99 失真。應該先看一段時間的實\n際分布，再決定 bucket 邊界要切在哪裡。",[100,2713,2715],{"id":2714},"_5-summary-用在多副本服務上","5. Summary 用在多副本服務上",[11,2717,2718,2719,2722],{},"如前面提到的，Summary 的分位數沒辦法跨實例正確聚合。在 Kubernetes 這種動輒\n數個到數十個副本的環境，用 Summary 監控 API 延遲，你看到的每個 Pod 的 p95\n都是「對」的，但你永遠沒辦法知道",[23,2720,2721],{},"整個服務","的 p95 是多少。",[28,2724,2725],{"id":2725},"選型的一句話總結",[11,2727,2728,2729,2731],{},"先問這是「發生次數」「當下狀態」還是「一批數值的分布」；發生次數用 Counter\n搭配 ",[15,2730,2389],{},"，當下狀態用 Gauge，分布（尤其是要看分位數）用 Histogram，比率\n一律用兩個 Counter 相除算出來，不要直接存成 Gauge，也盡量避免在多副本環境用\nSummary。",{"title":44,"searchDepth":386,"depth":386,"links":2733},[2734,2735,2741,2742,2749],{"id":2310,"depth":386,"text":2311},{"id":2335,"depth":386,"text":2336,"children":2736},[2737,2738,2739,2740],{"id":2339,"depth":397,"text":2340},{"id":2397,"depth":397,"text":2398},{"id":2436,"depth":397,"text":2437},{"id":2505,"depth":397,"text":2506},{"id":2529,"depth":386,"text":2529},{"id":2627,"depth":386,"text":2627,"children":2743},[2744,2745,2746,2747,2748],{"id":2630,"depth":397,"text":2631},{"id":2645,"depth":397,"text":2646},{"id":2672,"depth":397,"text":2673},{"id":2703,"depth":397,"text":2704},{"id":2714,"depth":397,"text":2715},{"id":2725,"depth":386,"text":2725},"2026-03-02",{},"\u002Farticles\u002Fprometheus-metric-types",{"title":2305,"description":44},"articles\u002Fprometheus-metric-types",[2756,2757,1166,2758],"Prometheus","Observability","指標設計","AWhUZMMxMUtfkWKUHMRc70Sy9uwbFaUspgEnEuYySSA",{"id":2761,"title":2762,"body":2763,"date":3030,"description":44,"excerpt":1156,"extension":1157,"meta":3031,"navigation":1035,"path":3032,"seo":3033,"stem":3034,"tags":3035,"__hash__":3040},"articles\u002Farticles\u002Falibaba-middle-platform-architecture.md","阿里巴巴中台架構筆記：前台、中台、後台，與「大中台，小前台」",{"type":8,"value":2764,"toc":3020},[2765,2769,2779,2789,2799,2803,2806,2826,2829,2833,2836,2850,2853,2857,2860,2884,2887,2977,2981,2987,2990,3000,3003,3006,3012,3015,3017],[28,2766,2768],{"id":2767},"前台中台後台是什麼","前台、中台、後台是什麼",[11,2770,2771,2774,2775,2778],{},[23,2772,2773],{},"前台（Front Office）","：直接面對使用者的業務入口，例如電商 App、官網、行銷活動頁、小程式。特性是",[23,2776,2777],{},"變化快","——今天要做一個促銷活動、明天要 A\u002FB test 一個新版面，前台必須能快速迭代、快速試錯，離使用者越近，改版壓力越大。",[11,2780,2781,2784,2785,2788],{},[23,2782,2783],{},"後台（Back Office）","：企業核心的交易紀錄與底層基礎設施，例如財務總帳、ERP、核心資料庫、機房與雲端基礎設施。特性是",[23,2786,2787],{},"極度穩定、變化慢","，任何改動都要求高一致性——財務資料算錯、資料庫壞掉，代價是無法接受的，所以後台通常寧可慢也要求穩。",[11,2790,2791,2794,2795,2798],{},[23,2792,2793],{},"中台（Middle Platform）","：介於前台與後台之間的一層",[23,2796,2797],{},"共用能力平台","。它把多個前台業務都需要、卻各自重複開發的能力（會員、交易、商品、支付、風控、資料統計……）抽象沉澱下來，變成一組可以被多個前台快速呼叫、組裝的公共服務，前台不需要每次都從零打造底層邏輯。",[28,2800,2802],{"id":2801},"為什麼會有中台它解決的問題","為什麼會有中台：它解決的問題",[11,2804,2805],{},"中台的概念在阿里巴巴內部從 2015 年底開始被系統性推動。當時阿里旗下有淘寶、天貓、聚划算等多個前台業務，各自維護一套自己的會員、商品、交易系統，長期下來出現幾個問題：",[66,2807,2808,2814,2820],{},[69,2809,2810,2813],{},[23,2811,2812],{},"重複造輪子","：每個前台業務都各自寫一套會員系統、交易系統，開發資源被大量浪費在「重新發明」已經有人做過的東西上",[69,2815,2816,2819],{},[23,2817,2818],{},"資料與體驗不一致","：不同前台的會員資料互相不通，使用者在一個平台累積的權益、行為紀錄，換一個前台就等於歸零",[69,2821,2822,2825],{},[23,2823,2824],{},"新業務上線慢","：任何一個新的前台業務構想，都要先花好幾個月把底層的會員、交易、支付能力重新搭一遍，才能開始做真正的業務創新",[11,2827,2828],{},"中台要解決的核心問題，就是把這些「共性能力」統一沉澱、統一維護，讓前台只需要「組裝」既有的中台服務，而不是每次都「重新建造」，藉此換取新業務更快的上線速度。",[28,2830,2832],{"id":2831},"大中台小前台","大中台，小前台",[11,2834,2835],{},"「大中台，小前台」是這套架構戰略最核心的一句口號，對應的是組織與技術架構同步的調整：",[66,2837,2838,2844],{},[69,2839,2840,2843],{},[23,2841,2842],{},"大中台","：把通用、共性的能力集中收攏，投入大量資源建設成一層強大、穩定、經過反覆驗證的共享服務層",[69,2845,2846,2849],{},[23,2847,2848],{},"小前台","：前台團隊因此可以變得精簡、輕量、敏捷，只需要專注在業務創新、使用者體驗與快速試錯上，底層能力全部交給中台提供",[11,2851,2852],{},"可以把它想成軍隊的比喻：前台是機動性高、快速應變的小規模特種部隊，中台則是後方龐大而穩固的補給、火力與情報支援體系——前線部隊不需要自己造槍造炮，只需要專心打仗。",[28,2854,2856],{"id":2855},"中台再細分業務中台資料中台技術中台","中台再細分：業務中台、資料中台、技術中台",[11,2858,2859],{},"實務上「中台」不是單一一層，通常會再細分成三種：",[66,2861,2862,2868,2874],{},[69,2863,2864,2867],{},[23,2865,2866],{},"業務中台（Business Middle Platform）","：把業務邏輯抽出來的共用能力中心，例如會員中心、交易中心、商品中心、支付中心、行銷中心、風控引擎——這一層「懂業務語意」，知道什麼是「訂單」「會員」「優惠券」",[69,2869,2870,2873],{},[23,2871,2872],{},"資料中台（Data Middle Platform）","：統一資料的採集、清洗、建模與指標定義，讓所有前台都用同一套「資料語言」看同一份事實，而不是每個前台各自算出不同口徑的「日活躍使用者數」",[69,2875,2876,2879,2880,2883],{},[23,2877,2878],{},"技術中台／研發中台","：底層通用的技術能力，例如容器編排、CI\u002FCD 流水線、可觀測性（日誌、追蹤、監控）、服務網格——這一層",[23,2881,2882],{},"不懂業務語意","，它不知道什麼是「訂單」，只提供運算、部署、監控這類跟業務無關的通用技術能力",[28,2885,2886],{"id":2886},"三層定位速查表",[132,2888,2889,2905],{},[135,2890,2891],{},[138,2892,2893,2896,2899,2902],{},[141,2894,2895],{},"層級",[141,2897,2898],{},"定位",[141,2900,2901],{},"特性",[141,2903,2904],{},"舉例",[152,2906,2907,2921,2935,2949,2963],{},[138,2908,2909,2912,2915,2918],{},[157,2910,2911],{},"前台",[157,2913,2914],{},"直接面對使用者的業務入口",[157,2916,2917],{},"變化快，需要快速迭代與試錯",[157,2919,2920],{},"電商 App、官網、行銷活動頁、小程式",[138,2922,2923,2926,2929,2932],{},[157,2924,2925],{},"業務中台",[157,2927,2928],{},"沉澱共用「業務」能力，供前台組裝呼叫",[157,2930,2931],{},"穩定、可重用、懂業務語意",[157,2933,2934],{},"會員中心、交易中心、商品中心、風控引擎",[138,2936,2937,2940,2943,2946],{},[157,2938,2939],{},"資料中台",[157,2941,2942],{},"統一資料採集、建模與指標口徑",[157,2944,2945],{},"全公司共用同一份「事實」",[157,2947,2948],{},"資料倉儲、指標平台、BI 報表",[138,2950,2951,2954,2957,2960],{},[157,2952,2953],{},"技術中台",[157,2955,2956],{},"提供跟業務無關的通用技術能力",[157,2958,2959],{},"服務所有前中後台，不懂業務語意",[157,2961,2962],{},"Kubernetes、CI\u002FCD、服務網格、可觀測性",[138,2964,2965,2968,2971,2974],{},[157,2966,2967],{},"後台",[157,2969,2970],{},"核心交易紀錄與底層基礎設施",[157,2972,2973],{},"極度穩定，變化慢，強一致性",[157,2975,2976],{},"ERP、財務總帳、核心資料庫、雲端基礎設施",[28,2978,2980],{"id":2979},"webapp-算哪一層kubernetes-又算哪一層","Web／App 算哪一層？Kubernetes 又算哪一層？",[11,2982,2983,2986],{},[23,2984,2985],{},"Web 官網、App 屬於前台。"," 它們是直接面對使用者的展示與互動層，會隨著不同市場、不同活動、不同 A\u002FB 測試快速改版。就算 Web／App 背後呼叫的是中台的 API，UI 本身、頁面組裝、互動邏輯、轉換率優化，都還是前台的範疇。",[11,2988,2989],{},"這裡有個常見的反模式值得注意：如果 Web／App 裡面不只有 UI，還把業務規則也寫死在前端（例如訂單金額的計算邏輯直接寫在前端程式碼裡），代表中台化並不徹底——理想狀態下，前端只負責展示與互動，實際的業務規則應該呼叫業務中台的 API 取得結果，而不是自己算一份。",[11,2991,2992,2995,2996,2999],{},[23,2993,2994],{},"Kubernetes 屬於技術中台","，或者用更寬鬆的三層分法來說，可以歸在「後台的基礎設施層」。判斷的關鍵在於：K8s 提供的容器編排、彈性擴縮、服務發現，是",[23,2997,2998],{},"跟業務完全無關","的通用技術能力——它不知道也不需要知道什麼是「訂單」「會員」，任何前台、業務中台、資料中台的服務，全部都跑在它上面、共用同一套部署與維運能力。這正是技術中台跟業務中台最大的差異：業務中台懂業務語意，技術中台只管運算與部署，兩者提供「共用能力」的方式一樣，但共用的東西完全不同層次。",[28,3001,3002],{"id":3002},"中台架構圖",[11,3004,3005],{},"把前面幾節串起來，一個典型的中台架構大致長這樣：",[11,3007,3008],{},[1952,3009],{"alt":3010,"src":3011},"阿里巴巴中台架構圖：前台呼叫中台的業務中台、資料中台、技術中台三塊共用能力，中台再依賴後台的核心資料與基礎設施","\u002Fimages\u002Farticles\u002Falibaba-middle-platform.svg",[11,3013,3014],{},"前台在最上層負責跟使用者互動，遇到需要「共用能力」的地方（會員、交易、風控、部署、監控）就往中台這一層呼叫，而不是自己重寫；中台再往下依賴後台的核心資料與基礎設施。三層分工清楚，才有辦法讓前台維持「小而快」，中台維持「大而穩」。",[28,3016,1890],{"id":1890},[11,3018,3019],{},"前台負責「變化快、貼近使用者」，後台負責「穩、慢、強一致」，中台則是夾在中間、把多個前台重複需要的能力沉澱成共用服務的那一層；業務中台懂業務語意，技術中台（像 Kubernetes）不懂業務、只提供跟業務無關的通用技術能力，兩者都是「中台」，但服務的對象與內容完全不同層次。",{"title":44,"searchDepth":386,"depth":386,"links":3021},[3022,3023,3024,3025,3026,3027,3028,3029],{"id":2767,"depth":386,"text":2768},{"id":2801,"depth":386,"text":2802},{"id":2831,"depth":386,"text":2832},{"id":2855,"depth":386,"text":2856},{"id":2886,"depth":386,"text":2886},{"id":2979,"depth":386,"text":2980},{"id":3002,"depth":386,"text":3002},{"id":1890,"depth":386,"text":1890},"2026-02-16",{},"\u002Farticles\u002Falibaba-middle-platform-architecture",{"title":2762,"description":44},"articles\u002Falibaba-middle-platform-architecture",[3036,3037,3038,1163,3039],"中台","架構設計","平台工程","系統設計","GGSimJ53pO4cPMr9IEF12V1R6tA27nVE6Ma0JhDpyFg",{"id":3042,"title":3043,"body":3044,"date":3896,"description":3897,"excerpt":1156,"extension":1157,"meta":3898,"navigation":1035,"path":121,"seo":3899,"stem":3900,"tags":3901,"__hash__":3904},"articles\u002Farticles\u002Fdotnet-k8s-health-checks.md","Kubernetes 探針怎麼設計？Liveness、Readiness、Startup 的責任劃分",{"type":8,"value":3045,"toc":3890},[3046,3059,3062,3128,3134,3138,3143,3260,3382,3395,3556,3570,3749,3753,3760,3854,3865,3867,3887],[11,3047,3048,3049,3052,3053,114],{},"Kubernetes 的三種探針（Liveness、Readiness、Startup）機制本身不難，官方文件\n也寫得很清楚。真正常出問題的地方，是",[23,3050,3051],{},"把不該放進某個探針的檢查放了進去","——\n最常見的就是把「檢查資料庫連線」這種較重的邏輯放進 Liveness，結果資料庫一慢，\n整個服務被 Kubernetes 誤判成掛掉，開始無限重啟，把本來只是慢的問題直接搞成中\n斷。完整可執行的範例程式碼放在\n",[119,3054,3058],{"href":3055,"rel":3056},"https:\u002F\u002Fgithub.com\u002Ftom3019\u002Fdotnet-k8s-health-checks",[3057],"nofollow","dotnet-k8s-health-checks",[28,3060,3061],{"id":3061},"三種探針各自該檢查什麼",[132,3063,3064,3077],{},[135,3065,3066],{},[138,3067,3068,3071,3074],{},[141,3069,3070],{},"探針",[141,3072,3073],{},"應檢查的面向",[141,3075,3076],{},"失敗後的效果",[152,3078,3079,3096,3114],{},[138,3080,3081,3087,3090],{},[157,3082,3083,3086],{},[23,3084,3085],{},"Liveness","（存活）",[157,3088,3089],{},"進程是否卡死、是否陷入無限迴圈、主要資源是否已不可回復（如主執行緒崩潰）",[157,3091,3092,3093],{},"容器被 Kubernetes ",[23,3094,3095],{},"重啟",[138,3097,3098,3104,3107],{},[157,3099,3100,3103],{},[23,3101,3102],{},"Readiness","（就緒）",[157,3105,3106],{},"啟動流程是否已完成、依賴（DB、Redis、外部服務）是否連通、必要資源（cache、config）是否已載入",[157,3108,3109,3110,3113],{},"Pod 從 Service 的 Endpoints ",[23,3111,3112],{},"移除","，暫停接收流量",[138,3115,3116,3122,3125],{},[157,3117,3118,3121],{},[23,3119,3120],{},"Startup","（啟動）",[157,3123,3124],{},"啟動邏輯是否完成（如 migration、依賴初始化）",[157,3126,3127],{},"成功前暫停 Liveness 判斷，避免應用還沒啟動完就被判定存活失敗而重啟",[11,3129,3130,3131],{},"關鍵原則：",[23,3132,3133],{},"Liveness 要極度輕量，只回答「進程還活著嗎」；真正檢查依賴是否可\n用的邏輯，屬於 Readiness 或 Startup。",[28,3135,3137],{"id":3136},"實作範例aspnet-core","實作範例（ASP.NET Core）",[11,3139,3140,3142],{},[23,3141,3120],{},"：啟動期間做完整的依賴初始化檢查（資料庫、Redis、Kafka topic 等\n較耗時的項目）：",[36,3144,3148],{"className":3145,"code":3146,"language":3147,"meta":44,"style":44},"language-csharp shiki shiki-themes github-dark","\u002F\u002F Program.cs\nbuilder.Services.AddHealthChecks()\n    .AddCheck\u003CStartupHealthCheck>(\"startup\", tags: new[] { \"startup\" });\n\napp.MapHealthChecks(\"\u002Fprobe\u002Fstartup\",\n    new HealthCheckOptions { Predicate = c => c.Tags.Contains(\"startup\") });\n","csharp",[15,3149,3150,3155,3166,3206,3210,3227],{"__ignoreMap":44},[376,3151,3152],{"class":378,"line":379},[376,3153,3154],{"class":382},"\u002F\u002F Program.cs\n",[376,3156,3157,3160,3163],{"class":378,"line":386},[376,3158,3159],{"class":393},"builder.Services.",[376,3161,3162],{"class":1020},"AddHealthChecks",[376,3164,3165],{"class":393},"()\n",[376,3167,3168,3171,3174,3177,3180,3183,3186,3189,3192,3194,3198,3201,3203],{"class":378,"line":397},[376,3169,3170],{"class":393},"    .",[376,3172,3173],{"class":1020},"AddCheck",[376,3175,3176],{"class":393},"\u003C",[376,3178,3179],{"class":1020},"StartupHealthCheck",[376,3181,3182],{"class":393},">(",[376,3184,3185],{"class":414},"\"startup\"",[376,3187,3188],{"class":393},", ",[376,3190,3191],{"class":1020},"tags",[376,3193,411],{"class":393},[376,3195,3197],{"class":3196},"snl16","new",[376,3199,3200],{"class":393},"[] { ",[376,3202,3185],{"class":414},[376,3204,3205],{"class":393}," });\n",[376,3207,3208],{"class":378,"line":405},[376,3209,1036],{"emptyLinePlaceholder":1035},[376,3211,3212,3215,3218,3221,3224],{"class":378,"line":418},[376,3213,3214],{"class":393},"app.",[376,3216,3217],{"class":1020},"MapHealthChecks",[376,3219,3220],{"class":393},"(",[376,3222,3223],{"class":414},"\"\u002Fprobe\u002Fstartup\"",[376,3225,3226],{"class":393},",\n",[376,3228,3229,3232,3235,3238,3241,3244,3247,3250,3253,3255,3257],{"class":378,"line":429},[376,3230,3231],{"class":3196},"    new",[376,3233,3234],{"class":1020}," HealthCheckOptions",[376,3236,3237],{"class":393}," { Predicate ",[376,3239,3240],{"class":3196},"=",[376,3242,3243],{"class":1020}," c",[376,3245,3246],{"class":3196}," =>",[376,3248,3249],{"class":393}," c.Tags.",[376,3251,3252],{"class":1020},"Contains",[376,3254,3220],{"class":393},[376,3256,3185],{"class":414},[376,3258,3259],{"class":393},") });\n",[36,3261,3263],{"className":3145,"code":3262,"language":3147,"meta":44,"style":44},"\u002F\u002F StartupHealthCheck.cs（節錄）\npublic async Task\u003CHealthCheckResult> CheckHealthAsync(\n    HealthCheckContext context, CancellationToken cancellationToken = default)\n{\n    await DatabaseConnectionCheckAsync(cancellationToken);\n    await RedisConnectionCheckAsync(cancellationToken);\n    await KafkaConnectionCheckAsync(cancellationToken);\n\n    return HealthCheckResult.Healthy(\"All dependencies initialized.\");\n}\n",[15,3264,3265,3270,3295,3320,3325,3336,3345,3354,3358,3377],{"__ignoreMap":44},[376,3266,3267],{"class":378,"line":379},[376,3268,3269],{"class":382},"\u002F\u002F StartupHealthCheck.cs（節錄）\n",[376,3271,3272,3275,3278,3281,3283,3286,3289,3292],{"class":378,"line":386},[376,3273,3274],{"class":3196},"public",[376,3276,3277],{"class":3196}," async",[376,3279,3280],{"class":1020}," Task",[376,3282,3176],{"class":393},[376,3284,3285],{"class":1020},"HealthCheckResult",[376,3287,3288],{"class":393},"> ",[376,3290,3291],{"class":1020},"CheckHealthAsync",[376,3293,3294],{"class":393},"(\n",[376,3296,3297,3300,3303,3305,3308,3311,3314,3317],{"class":378,"line":397},[376,3298,3299],{"class":1020},"    HealthCheckContext",[376,3301,3302],{"class":1020}," context",[376,3304,3188],{"class":393},[376,3306,3307],{"class":1020},"CancellationToken",[376,3309,3310],{"class":1020}," cancellationToken",[376,3312,3313],{"class":3196}," =",[376,3315,3316],{"class":3196}," default",[376,3318,3319],{"class":393},")\n",[376,3321,3322],{"class":378,"line":405},[376,3323,3324],{"class":393},"{\n",[376,3326,3327,3330,3333],{"class":378,"line":418},[376,3328,3329],{"class":3196},"    await",[376,3331,3332],{"class":1020}," DatabaseConnectionCheckAsync",[376,3334,3335],{"class":393},"(cancellationToken);\n",[376,3337,3338,3340,3343],{"class":378,"line":429},[376,3339,3329],{"class":3196},[376,3341,3342],{"class":1020}," RedisConnectionCheckAsync",[376,3344,3335],{"class":393},[376,3346,3347,3349,3352],{"class":378,"line":437},[376,3348,3329],{"class":3196},[376,3350,3351],{"class":1020}," KafkaConnectionCheckAsync",[376,3353,3335],{"class":393},[376,3355,3356],{"class":378,"line":447},[376,3357,1036],{"emptyLinePlaceholder":1035},[376,3359,3360,3363,3366,3369,3371,3374],{"class":378,"line":531},[376,3361,3362],{"class":3196},"    return",[376,3364,3365],{"class":393}," HealthCheckResult.",[376,3367,3368],{"class":1020},"Healthy",[376,3370,3220],{"class":393},[376,3372,3373],{"class":414},"\"All dependencies initialized.\"",[376,3375,3376],{"class":393},");\n",[376,3378,3379],{"class":378,"line":542},[376,3380,3381],{"class":393},"}\n",[11,3383,3384,3386,3387,3390,3391,3394],{},[23,3385,3102],{},"：只做快速、輕量的可達性檢查（",[15,3388,3389],{},"SELECT 1","、Redis ",[15,3392,3393],{},"PING","），不做\n完整初始化邏輯：",[36,3396,3398],{"className":3145,"code":3397,"language":3147,"meta":44,"style":44},"\u002F\u002F ReadinessHealthCheck.cs（節錄）\npublic static volatile bool IsShutdownInitiated = false;\n\npublic async Task\u003CHealthCheckResult> CheckHealthAsync(\n    HealthCheckContext context, CancellationToken cancellationToken = default)\n{\n    if (IsShutdownInitiated)\n        return HealthCheckResult.Unhealthy(\"App is stopping\");\n\n    await DatabaseConnectionCheckAsync(cancellationToken); \u002F\u002F quick check\n    await redis.GetDatabase().PingAsync();\n\n    return HealthCheckResult.Healthy(\"Ready: dependencies are reachable.\");\n}\n",[15,3399,3400,3405,3429,3433,3451,3469,3473,3481,3498,3502,3514,3533,3537,3552],{"__ignoreMap":44},[376,3401,3402],{"class":378,"line":379},[376,3403,3404],{"class":382},"\u002F\u002F ReadinessHealthCheck.cs（節錄）\n",[376,3406,3407,3409,3412,3415,3418,3421,3423,3426],{"class":378,"line":386},[376,3408,3274],{"class":3196},[376,3410,3411],{"class":3196}," static",[376,3413,3414],{"class":3196}," volatile",[376,3416,3417],{"class":3196}," bool",[376,3419,3420],{"class":1020}," IsShutdownInitiated",[376,3422,3313],{"class":3196},[376,3424,3425],{"class":560}," false",[376,3427,3428],{"class":393},";\n",[376,3430,3431],{"class":378,"line":397},[376,3432,1036],{"emptyLinePlaceholder":1035},[376,3434,3435,3437,3439,3441,3443,3445,3447,3449],{"class":378,"line":405},[376,3436,3274],{"class":3196},[376,3438,3277],{"class":3196},[376,3440,3280],{"class":1020},[376,3442,3176],{"class":393},[376,3444,3285],{"class":1020},[376,3446,3288],{"class":393},[376,3448,3291],{"class":1020},[376,3450,3294],{"class":393},[376,3452,3453,3455,3457,3459,3461,3463,3465,3467],{"class":378,"line":418},[376,3454,3299],{"class":1020},[376,3456,3302],{"class":1020},[376,3458,3188],{"class":393},[376,3460,3307],{"class":1020},[376,3462,3310],{"class":1020},[376,3464,3313],{"class":3196},[376,3466,3316],{"class":3196},[376,3468,3319],{"class":393},[376,3470,3471],{"class":378,"line":429},[376,3472,3324],{"class":393},[376,3474,3475,3478],{"class":378,"line":437},[376,3476,3477],{"class":3196},"    if",[376,3479,3480],{"class":393}," (IsShutdownInitiated)\n",[376,3482,3483,3486,3488,3491,3493,3496],{"class":378,"line":447},[376,3484,3485],{"class":3196},"        return",[376,3487,3365],{"class":393},[376,3489,3490],{"class":1020},"Unhealthy",[376,3492,3220],{"class":393},[376,3494,3495],{"class":414},"\"App is stopping\"",[376,3497,3376],{"class":393},[376,3499,3500],{"class":378,"line":531},[376,3501,1036],{"emptyLinePlaceholder":1035},[376,3503,3504,3506,3508,3511],{"class":378,"line":542},[376,3505,3329],{"class":3196},[376,3507,3332],{"class":1020},[376,3509,3510],{"class":393},"(cancellationToken); ",[376,3512,3513],{"class":382},"\u002F\u002F quick check\n",[376,3515,3516,3518,3521,3524,3527,3530],{"class":378,"line":552},[376,3517,3329],{"class":3196},[376,3519,3520],{"class":393}," redis.",[376,3522,3523],{"class":1020},"GetDatabase",[376,3525,3526],{"class":393},"().",[376,3528,3529],{"class":1020},"PingAsync",[376,3531,3532],{"class":393},"();\n",[376,3534,3535],{"class":378,"line":564},[376,3536,1036],{"emptyLinePlaceholder":1035},[376,3538,3539,3541,3543,3545,3547,3550],{"class":378,"line":575},[376,3540,3362],{"class":3196},[376,3542,3365],{"class":393},[376,3544,3368],{"class":1020},[376,3546,3220],{"class":393},[376,3548,3549],{"class":414},"\"Ready: dependencies are reachable.\"",[376,3551,3376],{"class":393},[376,3553,3554],{"class":378,"line":583},[376,3555,3381],{"class":393},[11,3557,3558,3561,3562,3565,3566,3569],{},[23,3559,3560],{},"Deployment YAML","：三種探針的典型設定，注意 ",[15,3563,3564],{},"failureThreshold"," 與\n",[15,3567,3568],{},"periodSeconds"," 的搭配——Startup 給足夠的容錯次數（範例中 30 次 × 10 秒 = 最\n多容忍 5 分鐘啟動時間），Liveness／Readiness 則用較短的檢查週期：",[36,3571,3573],{"className":370,"code":3572,"language":372,"meta":44,"style":44},"livenessProbe:\n  httpGet: { path: \u002Fprobe\u002Flive, port: 8080 }\n  initialDelaySeconds: 10\n  periodSeconds: 10\n  failureThreshold: 3\n\nreadinessProbe:\n  httpGet: { path: \u002Fprobe\u002Fready, port: 8080 }\n  initialDelaySeconds: 5\n  periodSeconds: 5\n  failureThreshold: 3\n\nstartupProbe:\n  httpGet: { path: \u002Fprobe\u002Fstartup, port: 8080 }\n  periodSeconds: 10\n  failureThreshold: 30\n",[15,3574,3575,3582,3611,3620,3629,3639,3643,3650,3673,3682,3690,3698,3702,3709,3732,3740],{"__ignoreMap":44},[376,3576,3577,3580],{"class":378,"line":379},[376,3578,3579],{"class":389},"livenessProbe",[376,3581,394],{"class":393},[376,3583,3584,3587,3590,3593,3595,3598,3600,3603,3605,3608],{"class":378,"line":386},[376,3585,3586],{"class":389},"  httpGet",[376,3588,3589],{"class":393},": { ",[376,3591,3592],{"class":389},"path",[376,3594,411],{"class":393},[376,3596,3597],{"class":414},"\u002Fprobe\u002Flive",[376,3599,3188],{"class":393},[376,3601,3602],{"class":389},"port",[376,3604,411],{"class":393},[376,3606,3607],{"class":560},"8080",[376,3609,3610],{"class":393}," }\n",[376,3612,3613,3616,3618],{"class":378,"line":397},[376,3614,3615],{"class":389},"  initialDelaySeconds",[376,3617,411],{"class":393},[376,3619,572],{"class":560},[376,3621,3622,3625,3627],{"class":378,"line":405},[376,3623,3624],{"class":389},"  periodSeconds",[376,3626,411],{"class":393},[376,3628,572],{"class":560},[376,3630,3631,3634,3636],{"class":378,"line":418},[376,3632,3633],{"class":389},"  failureThreshold",[376,3635,411],{"class":393},[376,3637,3638],{"class":560},"3\n",[376,3640,3641],{"class":378,"line":429},[376,3642,1036],{"emptyLinePlaceholder":1035},[376,3644,3645,3648],{"class":378,"line":437},[376,3646,3647],{"class":389},"readinessProbe",[376,3649,394],{"class":393},[376,3651,3652,3654,3656,3658,3660,3663,3665,3667,3669,3671],{"class":378,"line":447},[376,3653,3586],{"class":389},[376,3655,3589],{"class":393},[376,3657,3592],{"class":389},[376,3659,411],{"class":393},[376,3661,3662],{"class":414},"\u002Fprobe\u002Fready",[376,3664,3188],{"class":393},[376,3666,3602],{"class":389},[376,3668,411],{"class":393},[376,3670,3607],{"class":560},[376,3672,3610],{"class":393},[376,3674,3675,3677,3679],{"class":378,"line":531},[376,3676,3615],{"class":389},[376,3678,411],{"class":393},[376,3680,3681],{"class":560},"5\n",[376,3683,3684,3686,3688],{"class":378,"line":542},[376,3685,3624],{"class":389},[376,3687,411],{"class":393},[376,3689,3681],{"class":560},[376,3691,3692,3694,3696],{"class":378,"line":552},[376,3693,3633],{"class":389},[376,3695,411],{"class":393},[376,3697,3638],{"class":560},[376,3699,3700],{"class":378,"line":564},[376,3701,1036],{"emptyLinePlaceholder":1035},[376,3703,3704,3707],{"class":378,"line":575},[376,3705,3706],{"class":389},"startupProbe",[376,3708,394],{"class":393},[376,3710,3711,3713,3715,3717,3719,3722,3724,3726,3728,3730],{"class":378,"line":583},[376,3712,3586],{"class":389},[376,3714,3589],{"class":393},[376,3716,3592],{"class":389},[376,3718,411],{"class":393},[376,3720,3721],{"class":414},"\u002Fprobe\u002Fstartup",[376,3723,3188],{"class":393},[376,3725,3602],{"class":389},[376,3727,411],{"class":393},[376,3729,3607],{"class":560},[376,3731,3610],{"class":393},[376,3733,3734,3736,3738],{"class":378,"line":597},[376,3735,3624],{"class":389},[376,3737,411],{"class":393},[376,3739,572],{"class":560},[376,3741,3742,3744,3746],{"class":378,"line":605},[376,3743,3633],{"class":389},[376,3745,411],{"class":393},[376,3747,3748],{"class":560},"30\n",[28,3750,3752],{"id":3751},"容易漏掉的一步優雅退出graceful-shutdown","容易漏掉的一步：優雅退出（Graceful Shutdown）",[11,3754,3755,3756,3759],{},"Pod 被終止時，Kubernetes 會先送出 SIGTERM，但",[23,3757,3758],{},"這個當下 Service 的 Endpoints\n不會立刻更新","——如果應用馬上就地關閉，仍在路上的請求會直接被中斷。正確順序\n是收到 SIGTERM 後，先把 Readiness 轉成 Unhealthy，等一段排空（drain）時間，\n再真正結束程序：",[36,3761,3763],{"className":3145,"code":3762,"language":3147,"meta":44,"style":44},"app.Lifetime.ApplicationStopping.Register(() =>\n{\n    \u002F\u002F 1. 讓 Readiness 立刻回傳 Unhealthy，\n    \u002F\u002F    Pod 會從 Service Endpoints 移除，不再收到新流量\n    ReadinessHealthCheck.IsShutdownInitiated = true;\n\n    \u002F\u002F 2. 等待 Endpoints 更新擴散到所有 Node／Load Balancer，\n    \u002F\u002F    讓既有連線把手上的請求做完（drain）\n    Thread.Sleep(TimeSpan.FromSeconds(10));\n\n    \u002F\u002F 3. 排空結束才真正進入資源釋放與終止流程\n});\n",[15,3764,3765,3779,3783,3788,3793,3805,3809,3814,3819,3840,3844,3849],{"__ignoreMap":44},[376,3766,3767,3770,3773,3776],{"class":378,"line":379},[376,3768,3769],{"class":393},"app.Lifetime.ApplicationStopping.",[376,3771,3772],{"class":1020},"Register",[376,3774,3775],{"class":393},"(() ",[376,3777,3778],{"class":3196},"=>\n",[376,3780,3781],{"class":378,"line":386},[376,3782,3324],{"class":393},[376,3784,3785],{"class":378,"line":397},[376,3786,3787],{"class":382},"    \u002F\u002F 1. 讓 Readiness 立刻回傳 Unhealthy，\n",[376,3789,3790],{"class":378,"line":405},[376,3791,3792],{"class":382},"    \u002F\u002F    Pod 會從 Service Endpoints 移除，不再收到新流量\n",[376,3794,3795,3798,3800,3803],{"class":378,"line":418},[376,3796,3797],{"class":393},"    ReadinessHealthCheck.IsShutdownInitiated ",[376,3799,3240],{"class":3196},[376,3801,3802],{"class":560}," true",[376,3804,3428],{"class":393},[376,3806,3807],{"class":378,"line":429},[376,3808,1036],{"emptyLinePlaceholder":1035},[376,3810,3811],{"class":378,"line":437},[376,3812,3813],{"class":382},"    \u002F\u002F 2. 等待 Endpoints 更新擴散到所有 Node／Load Balancer，\n",[376,3815,3816],{"class":378,"line":447},[376,3817,3818],{"class":382},"    \u002F\u002F    讓既有連線把手上的請求做完（drain）\n",[376,3820,3821,3824,3827,3830,3833,3835,3837],{"class":378,"line":531},[376,3822,3823],{"class":393},"    Thread.",[376,3825,3826],{"class":1020},"Sleep",[376,3828,3829],{"class":393},"(TimeSpan.",[376,3831,3832],{"class":1020},"FromSeconds",[376,3834,3220],{"class":393},[376,3836,361],{"class":560},[376,3838,3839],{"class":393},"));\n",[376,3841,3842],{"class":378,"line":542},[376,3843,1036],{"emptyLinePlaceholder":1035},[376,3845,3846],{"class":378,"line":552},[376,3847,3848],{"class":382},"    \u002F\u002F 3. 排空結束才真正進入資源釋放與終止流程\n",[376,3850,3851],{"class":378,"line":564},[376,3852,3853],{"class":393},"});\n",[11,3855,3856,3857,3860,3861,3864],{},"Drain 時間要抓多長，取決於應用最長的請求處理時間與 Endpoints 更新擴散的延\n遲，10～30 秒是常見起點；如果有更長的清理作業，要另外搭配\n",[15,3858,3859],{},"terminationGracePeriodSeconds"," 與 ",[15,3862,3863],{},"preStop"," hook，避免清理做到一半就被\nKubernetes 強制 kill。",[28,3866,1095],{"id":1095},[66,3868,3869,3874,3879,3884],{},[69,3870,3871,3873],{},[23,3872,3120],{}," 解決「啟動慢被 Liveness 誤判」的問題",[69,3875,3876,3878],{},[23,3877,3085],{}," 只保證容器能自我療癒（卡死時重啟），檢查邏輯必須極輕量",[69,3880,3881,3883],{},[23,3882,3102],{}," 控制流量路由，決定 Pod 準備好了沒、要不要收新請求",[69,3885,3886],{},"關閉服務時，Readiness 要先轉 Unhealthy 再等 drain，而不是直接關閉程序",[1132,3888,3889],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":44,"searchDepth":386,"depth":386,"links":3891},[3892,3893,3894,3895],{"id":3061,"depth":386,"text":3061},{"id":3136,"depth":386,"text":3137},{"id":3751,"depth":386,"text":3752},{"id":1095,"depth":386,"text":1095},"2025-11-23","Kubernetes 的三種探針（Liveness、Readiness、Startup）機制本身不難，官方文件\n也寫得很清楚。真正常出問題的地方，是把不該放進某個探針的檢查放了進去——\n最常見的就是把「檢查資料庫連線」這種較重的邏輯放進 Liveness，結果資料庫一慢，\n整個服務被 Kubernetes 誤判成掛掉，開始無限重啟，把本來只是慢的問題直接搞成中\n斷。完整可執行的範例程式碼放在\ndotnet-k8s-health-checks。",{},{"title":3043,"description":3897},"articles\u002Fdotnet-k8s-health-checks",[1163,3902,3903,1166],"ASP.NET Core","Health Check","HbgtK1pXqCNl6Xw96bT_y4d8aeYqTwUPubiXvkuZN3Y",{"id":3906,"title":3907,"body":3908,"date":4557,"description":4558,"excerpt":1156,"extension":1157,"meta":4559,"navigation":1035,"path":4560,"seo":4561,"stem":4562,"tags":4563,"__hash__":4566},"articles\u002Farticles\u002Fkafka-vs-rabbitmq.md","Kafka 與 RabbitMQ 差在哪？從「佇列」和「日誌」兩種模型看怎麼選",{"type":8,"value":3909,"toc":4540},[3910,3935,3938,3942,3948,3953,3981,3986,4010,4013,4016,4164,4168,4172,4203,4210,4214,4236,4243,4247,4270,4272,4288,4291,4310,4313,4316,4328,4331,4334,4350,4353,4358,4390,4395,4427,4433,4437,4440,4485,4492,4495,4535,4537],[11,3911,3912,3913,2276,3917,3921,3922,2276,3926,3930,3931,3934],{},"前面分別寫過 ",[119,3914,3916],{"href":3915},"\u002Farticles\u002Frabbitmq-exchange-queue-binding","RabbitMQ 的 Exchange、Queue、Binding",[119,3918,3920],{"href":3919},"\u002Farticles\u002Frabbitmq-ack-prefetch-dead-letter","RabbitMQ 的 Ack 與死信佇列","，以及 ",[119,3923,3925],{"href":3924},"\u002Farticles\u002Fkafka-topic-partition-consumer-group","Kafka 完整入門",[119,3927,3929],{"href":3928},"\u002Farticles\u002Fkafka-broadcast-ordering-k8s-scaling","Kafka 實務注意事項","。兩個我都在專案中用過，最常被問的問題就是：「",[23,3932,3933],{},"那到底要用哪一個？","」",[11,3936,3937],{},"答案要從它們最根本的差異開始講。",[28,3939,3941],{"id":3940},"核心差異佇列-vs-日誌","核心差異：佇列 vs 日誌",[11,3943,3944],{},[1952,3945],{"alt":3946,"src":3947},"RabbitMQ 與 Kafka 核心模型比較：RabbitMQ 是佇列模型，Producer 發布到 Exchange，由 Broker 依 Binding 路由並推送給 Consumer，Consumer Ack 之後訊息就從 Queue 刪除，Broker 負責記住每則訊息的狀態，是聰明的 Broker、單純的 Consumer；Kafka 是日誌模型，Producer 附加到 Partition 尾端，Consumer 自己拉取並記錄 Offset，訊息讀完不刪除、依保留期限清除，不同的 Consumer Group 可以各自讀到不同的位置甚至倒回重讀，是單純的 Broker、聰明的 Consumer","\u002Fimages\u002Farticles\u002Fkafka-vs-rabbitmq-model.svg",[11,3949,3950,55],{},[23,3951,3952],{},"RabbitMQ 是佇列（Queue）",[66,3954,3955,3958,3969,3975],{},[69,3956,3957],{},"Broker 負責路由：Producer 交給 Exchange，Exchange 依 Binding 決定複製到哪些 Queue",[69,3959,3960,3961,3964,3965,3968],{},"Broker ",[23,3962,3963],{},"主動推送","訊息給 Consumer，並追蹤",[23,3966,3967],{},"每一則訊息","的狀態（未投遞、處理中、已 Ack）",[69,3970,3971,3972],{},"Consumer Ack 之後，",[23,3973,3974],{},"訊息就從 Queue 刪除",[69,3976,3977,3978],{},"一句話：",[23,3979,3980],{},"聰明的 Broker、單純的 Consumer",[11,3982,3983,55],{},[23,3984,3985],{},"Kafka 是日誌（Log）",[66,3987,3988,3991,3998,4005],{},[69,3989,3990],{},"Producer 只是把訊息附加到 Partition 的尾端，Broker 不做路由",[69,3992,3993,3994,3997],{},"Consumer ",[23,3995,3996],{},"自己拉取","，自己記錄讀到哪個 Offset，Broker 不追蹤個別訊息",[69,3999,4000,4001,4004],{},"訊息讀完",[23,4002,4003],{},"不刪除","，依保留期限或容量清除，任何人都可以從任何位置重讀",[69,4006,3977,4007],{},[23,4008,4009],{},"單純的 Broker、聰明的 Consumer",[11,4011,4012],{},"幾乎所有其他的差異，都是從這裡推出來的。",[28,4014,4015],{"id":4015},"逐項比較",[132,4017,4018,4030],{},[135,4019,4020],{},[138,4021,4022,4024,4027],{},[141,4023],{},[141,4025,4026],{},"RabbitMQ",[141,4028,4029],{},"Kafka",[152,4031,4032,4043,4054,4065,4076,4087,4098,4109,4120,4131,4142,4153],{},[138,4033,4034,4037,4040],{},[157,4035,4036],{},"模型",[157,4038,4039],{},"佇列，處理完就刪除",[157,4041,4042],{},"日誌，依保留期限清除",[138,4044,4045,4048,4051],{},[157,4046,4047],{},"投遞方式",[157,4049,4050],{},"Broker 推送（Push）",[157,4052,4053],{},"Consumer 拉取（Pull）",[138,4055,4056,4059,4062],{},[157,4057,4058],{},"處理進度",[157,4060,4061],{},"Broker 追蹤每則訊息的 Ack",[157,4063,4064],{},"Consumer Group 記錄 Offset",[138,4066,4067,4070,4073],{},[157,4068,4069],{},"路由",[157,4071,4072],{},"Exchange + Binding，可依 Routing Key、萬用字元過濾",[157,4074,4075],{},"只有 Topic，過濾要在 Consumer 端做",[138,4077,4078,4081,4084],{},[157,4079,4080],{},"重讀歷史訊息",[157,4082,4083],{},"不行，Ack 後就消失",[157,4085,4086],{},"可以，把 Offset 倒回去",[138,4088,4089,4092,4095],{},[157,4090,4091],{},"分工的平行度",[157,4093,4094],{},"同一個 Queue 加 Consumer 就好，沒有上限",[157,4096,4097],{},"受 Partition 數量限制",[138,4099,4100,4103,4106],{},[157,4101,4102],{},"順序",[157,4104,4105],{},"單一 Queue 單一 Consumer 時有序",[157,4107,4108],{},"Partition 內有序，用 Key 控制",[138,4110,4111,4114,4117],{},[157,4112,4113],{},"單則失敗處理",[157,4115,4116],{},"Nack、Requeue、死信佇列，內建",[157,4118,4119],{},"沒有單則 Ack，重試與死信要自己做",[138,4121,4122,4125,4128],{},[157,4123,4124],{},"延遲訊息",[157,4126,4127],{},"TTL + 死信，或 Delayed Message 外掛",[157,4129,4130],{},"沒有內建",[138,4132,4133,4136,4139],{},[157,4134,4135],{},"優先權",[157,4137,4138],{},"支援 Priority Queue",[157,4140,4141],{},"不支援",[138,4143,4144,4147,4150],{},[157,4145,4146],{},"吞吐量",[157,4148,4149],{},"每秒數萬筆等級",[157,4151,4152],{},"每秒數十萬到百萬筆等級",[138,4154,4155,4158,4161],{},[157,4156,4157],{},"訊息堆積",[157,4159,4160],{},"堆積太多會影響效能，Queue 應該保持接近空",[157,4162,4163],{},"堆積是常態，資料本來就存在磁碟上",[28,4165,4167],{"id":4166},"同一個需求兩邊怎麼做","同一個需求，兩邊怎麼做",[100,4169,4171],{"id":4170},"分工多個-worker-一起處理","分工：多個 Worker 一起處理",[66,4173,4174,4187],{},[69,4175,4176,4178,4179,4182,4183,4186],{},[23,4177,4026],{},"：多個 Consumer 連到",[23,4180,4181],{},"同一個 Queue","（Competing Consumers）。工作量大就加 Consumer，",[23,4184,4185],{},"沒有上限","，搭配 Prefetch 讓慢的 Worker 少拿一點",[69,4188,4189,4191,4192,4198,4199,4202],{},[23,4190,4029],{},"：多個 Consumer 使用",[23,4193,4194,4195],{},"同一個 ",[15,4196,4197],{},"group.id","。平行度上限是 ",[23,4200,4201],{},"Partition 數量","，Consumer 比 Partition 多的部分只會閒置",[11,4204,4205,4206,4209],{},"所以像「產生報表」「轉檔」這種",[23,4207,4208],{},"每件工作要跑好幾秒到好幾分鐘","、數量又會暴增的任務，RabbitMQ 隨時加 Worker 的彈性比較好用。",[100,4211,4213],{"id":4212},"廣播每個服務都要收到","廣播：每個服務都要收到",[66,4215,4216,4225],{},[69,4217,4218,4220,4221,4224],{},[23,4219,4026],{},"：每個服務",[23,4222,4223],{},"各自宣告一個 Queue","，綁到同一個 Fanout 或 Topic Exchange",[69,4226,4227,4229,4230,4235],{},[23,4228,4029],{},"：每個服務使用",[23,4231,4232,4233],{},"不同的 ",[15,4234,4197],{}," 訂閱同一個 Topic",[11,4237,4238,4239,4242],{},"兩者都能做，但 Kafka 的優勢是：",[23,4240,4241],{},"新加入的服務可以從頭讀起","，拿到上線之前的歷史事件；RabbitMQ 的新 Queue 只會收到綁定之後的訊息。",[100,4244,4246],{"id":4245},"只收某些訊息路由與過濾","只收某些訊息：路由與過濾",[66,4248,4249,4261],{},[69,4250,4251,4253,4254,4257,4258],{},[23,4252,4026],{},"：Topic Exchange 的 Binding Key 用萬用字元過濾，例如 ",[15,4255,4256],{},"house.*.franchise"," 只收加盟物件，",[23,4259,4260],{},"不需要的訊息根本不會送到 Consumer",[69,4262,4263,4265,4266,4269],{},[23,4264,4029],{},"：Consumer 會讀到整個 Topic，",[23,4267,4268],{},"不需要的訊息要自己丟掉","。如果某類訊息只有少數 Consumer 關心，通常會拆成獨立的 Topic",[100,4271,4102],{"id":4102},[66,4273,4274,4279],{},[69,4275,4276,4278],{},[23,4277,4026],{},"：單一 Queue 的訊息是先進先出，但只要有多個 Consumer 或發生 Requeue，順序就不保證。要嚴格有序，只能一個 Queue 一個 Consumer，等於放棄平行處理",[69,4280,4281,4283,4284,4287],{},[23,4282,4029],{},"：用 Key 讓同一個實體的訊息進同一個 Partition，",[23,4285,4286],{},"同一個實體有序、不同實體平行","，這是 Kafka 的強項",[100,4289,4290],{"id":4290},"處理失敗的訊息",[66,4292,4293,4302],{},[69,4294,4295,4297,4298,4301],{},[23,4296,4026],{},"：每則訊息獨立 Ack，失敗的那一則可以 Nack 送進延遲重試或死信佇列，",[23,4299,4300],{},"其他訊息照常處理","。這些機制都是內建的",[69,4303,4304,4306,4307],{},[23,4305,4029],{},"：Consumer 提交的是「讀到哪個位置」，沒有「單獨標記某一則失敗」的概念。失敗的訊息要嘛原地重試、卡住整個 Partition，要嘛自己發到重試 Topic、死信 Topic，",[23,4308,4309],{},"全部要自己設計",[100,4311,4312],{"id":4312},"延遲處理",[11,4314,4315],{},"「訂單 30 分鐘沒付款就自動取消」這種需求：",[66,4317,4318,4323],{},[69,4319,4320,4322],{},[23,4321,4026],{},"：TTL 加死信，或安裝 Delayed Message 外掛，訊息到時間才投遞",[69,4324,4325,4327],{},[23,4326,4029],{},"：沒有內建，通常要另外搭配排程或資料庫",[100,4329,4330],{"id":4330},"事後重新處理",[11,4332,4333],{},"發現上週的資料處理錯了，修好程式要重跑一次：",[66,4335,4336,4341],{},[69,4337,4338,4340],{},[23,4339,4026],{},"：訊息早就刪掉了，只能從資料庫或其他來源重新產生",[69,4342,4343,4345,4346,4349],{},[23,4344,4029],{},"：只要還在保留期限內，",[23,4347,4348],{},"把 Consumer Group 的 Offset 倒回上週","，重新消費一次就好",[28,4351,4352],{"id":4352},"怎麼選",[11,4354,4355,55],{},[23,4356,4357],{},"選 RabbitMQ 的情境",[66,4359,4360,4366,4372,4378,4384],{},[69,4361,4362,4365],{},[23,4363,4364],{},"背景工作佇列","：寄信、產生 PDF、轉檔，每件工作獨立、處理時間長短不一，需要隨時增加 Worker",[69,4367,4368,4371],{},[23,4369,4370],{},"需要複雜路由","：依事件類型、來源、地區把訊息分給不同的系統，讓 Broker 幫忙過濾",[69,4373,4374,4377],{},[23,4375,4376],{},"每則訊息要個別處理成敗","：失敗的要重試、重試不了的進死信佇列，不能卡住其他訊息",[69,4379,4380,4383],{},[23,4381,4382],{},"需要延遲、優先權、RPC","（Request／Reply）這類訊息層級的功能",[69,4385,4386,4387],{},"訊息量是每秒數千到數萬筆，",[23,4388,4389],{},"處理完就不需要再看",[11,4391,4392,55],{},[23,4393,4394],{},"選 Kafka 的情境",[66,4396,4397,4403,4409,4415,4421],{},[69,4398,4399,4402],{},[23,4400,4401],{},"大量的事件串流","：日誌、監控指標、點擊紀錄、IoT 感測資料，每秒數十萬筆",[69,4404,4405,4408],{},[23,4406,4407],{},"同一份資料要給很多系統用","，而且新系統要能補讀歷史資料",[69,4410,4411,4414],{},[23,4412,4413],{},"需要重播","：修正錯誤後重新處理、用事件重建讀取模型",[69,4416,4417,4420],{},[23,4418,4419],{},"同一個實體的事件需要有序","，又需要大量平行處理，例如帳務、交易事件",[69,4422,4423,4426],{},[23,4424,4425],{},"CDC（Change Data Capture）","：把資料庫的異動串流到其他系統",[11,4428,4429,4430],{},"如果一句話：",[23,4431,4432],{},"要「把工作交出去」用 RabbitMQ，要「把發生的事情記下來，讓大家各自去讀」用 Kafka。",[28,4434,4436],{"id":4435},"使用情境一個系統同時用兩個","使用情境：一個系統同時用兩個",[11,4438,4439],{},"兩者並不互斥。以一個電商的下單流程為例：",[66,4441,4442,4467],{},[69,4443,4444,4445,4448,4449,4452,4453],{},"訂單建立、付款、出貨等",[23,4446,4447],{},"業務事件","寫進 Kafka 的 ",[15,4450,4451],{},"order-events","，以訂單 ID 為 Key：\n",[66,4454,4455,4461,4464],{},[69,4456,4457,4458,4460],{},"搜尋與報表服務各自用不同的 ",[15,4459,4197],{}," 消費，建立自己的讀取模型",[69,4462,4463],{},"資料分析平台從 Kafka 取得完整的歷史事件",[69,4465,4466],{},"讀取模型的邏輯改了，就把 Offset 倒回去重建",[69,4468,4469,4470,4473,4474],{},"「寄送訂單確認信」「產生電子發票 PDF」這類",[23,4471,4472],{},"背景工作","丟進 RabbitMQ：\n",[66,4475,4476,4479,4482],{},[69,4477,4478],{},"Worker 數量隨工作量隨時增減",[69,4480,4481],{},"寄信失敗就延遲重試，信箱格式錯誤就進死信佇列，不影響其他人的信",[69,4483,4484],{},"「30 分鐘未付款自動取消」用延遲訊息處理",[11,4486,4487,4488,4491],{},"判斷的方式是看",[23,4489,4490],{},"這則訊息的本質","：它是「一件已經發生、很多人可能有興趣的事實」，還是「一件需要有人去完成的工作」。",[28,4493,4494],{"id":4494},"注意事項",[66,4496,4497,4507,4513,4523,4529],{},[69,4498,4499,4502,4503,4506],{},[23,4500,4501],{},"不要把 Kafka 當工作佇列用","：處理時間長的工作容易超過 ",[15,4504,4505],{},"max.poll.interval.ms"," 觸發 Rebalance；一則失敗會卡住整個 Partition；Worker 數量又受 Partition 限制。這些都是 RabbitMQ 擅長的",[69,4508,4509,4512],{},[23,4510,4511],{},"不要把 RabbitMQ 當事件儲存用","：Queue 裡堆積大量訊息會吃記憶體與磁碟、拖慢效能，它的設計是讓 Queue 盡量保持接近空的狀態。需要長期保存、重播的，交給 Kafka 或資料庫",[69,4514,4515,4518,4519,4522],{},[23,4516,4517],{},"兩邊的「至少一次」都一樣","：不管是 RabbitMQ 的手動 Ack 還是 Kafka 的先處理再提交 Offset，",[23,4520,4521],{},"訊息都可能被處理不只一次","，Consumer 一定要冪等",[69,4524,4525,4528],{},[23,4526,4527],{},"維運成本不同","：RabbitMQ 單機就能跑起來，管理介面直覺；Kafka 需要規劃 Partition、複本、保留期限、磁碟容量，叢集維運的門檻比較高。訊息量不大、團隊又沒有 Kafka 經驗時，不要只因為「Kafka 比較快」就選它",[69,4530,4531,4534],{},[23,4532,4533],{},"兩者都在互相靠近","：RabbitMQ 3.9 加入了 Streams，提供類似 Kafka 的可重讀日誌；Kafka 4.0 也以 Early Access 推出了 Share Group（KIP-932），讓多個 Consumer 可以不受 Partition 限制地分工、逐筆確認。不過這些都還是各自的「延伸功能」，選型時還是以核心模型為主",[28,4536,1890],{"id":1890},[11,4538,4539],{},"RabbitMQ 是聰明的 Broker 加處理完就刪除的佇列，擅長路由、逐筆確認、延遲與重試，適合把工作交出去；Kafka 是單純的 Broker 加可以重讀的日誌，擅長大吞吐量、多系統各自消費、依 Key 有序與重播，適合把發生的事記下來讓大家各自讀；分清楚訊息是「工作」還是「事實」，就知道該放在哪裡。",{"title":44,"searchDepth":386,"depth":386,"links":4541},[4542,4543,4544,4553,4554,4555,4556],{"id":3940,"depth":386,"text":3941},{"id":4015,"depth":386,"text":4015},{"id":4166,"depth":386,"text":4167,"children":4545},[4546,4547,4548,4549,4550,4551,4552],{"id":4170,"depth":397,"text":4171},{"id":4212,"depth":397,"text":4213},{"id":4245,"depth":397,"text":4246},{"id":4102,"depth":397,"text":4102},{"id":4290,"depth":397,"text":4290},{"id":4312,"depth":397,"text":4312},{"id":4330,"depth":397,"text":4330},{"id":4352,"depth":386,"text":4352},{"id":4435,"depth":386,"text":4436},{"id":4494,"depth":386,"text":4494},{"id":1890,"depth":386,"text":1890},"2025-09-16","前面分別寫過 RabbitMQ 的 Exchange、Queue、Binding、RabbitMQ 的 Ack 與死信佇列，以及 Kafka 完整入門、Kafka 實務注意事項。兩個我都在專案中用過，最常被問的問題就是：「那到底要用哪一個？」",{},"\u002Farticles\u002Fkafka-vs-rabbitmq",{"title":3907,"description":4558},"articles\u002Fkafka-vs-rabbitmq",[4029,4026,4564,4565,3039],"Message Queue","事件驅動","soWPtmtjPIvk2bATkXgPKXPBZ9wVF9UHHIfESfsHJyw",{"id":4568,"title":4569,"body":4570,"date":5893,"description":5894,"excerpt":1156,"extension":1157,"meta":5895,"navigation":1035,"path":3928,"seo":5896,"stem":5897,"tags":5898,"__hash__":5900},"articles\u002Farticles\u002Fkafka-broadcast-ordering-k8s-scaling.md","Kafka 實務注意事項：廣播怎麼做、順序怎麼保、group.id 怎麼取，以及 K8s 自動擴展為什麼沒有變快",{"type":8,"value":4571,"toc":5869},[4572,4584,4598,4602,4608,4618,4622,4627,4633,4643,4647,4654,4666,4675,4767,4770,4804,4808,4815,4818,4874,4877,4881,4888,4919,4923,4929,4940,4946,5132,5143,5146,5149,5163,5170,5174,5184,5188,5193,5197,5206,5221,5227,5231,5245,5249,5262,5265,5303,5307,5313,5316,5333,5343,5346,5369,5372,5377,5380,5385,5398,5563,5581,5586,5635,5819,5823,5830,5833,5855,5857,5866],[11,4573,4574,4575,4577,4578,4583],{},"Kafka 的基本元件可以先參考 ",[119,4576,3925],{"href":3924},"。這篇只講一件事背後的各種變化：",[23,4579,4580,4582],{},[15,4581,4197],{}," 與 Partition 的分配規則","。幾乎所有 Kafka 在應用上的坑，最後都會回到這兩條規則：",[211,4585,4586,4592],{},[69,4587,4588,4591],{},[23,4589,4590],{},"同一個 Group 內，一個 Partition 只會分給一個 Consumer","（分工）",[69,4593,4594,4597],{},[23,4595,4596],{},"不同的 Group 之間互不影響，各自讀到完整的資料","（各讀一份）",[28,4599,4601],{"id":4600},"一廣播讓每個人都收到同一則訊息","一、廣播：讓每個人都收到同一則訊息",[11,4603,4604],{},[1952,4605],{"alt":4606,"src":4607},"Kafka 三種消費模式：模式一，同一個服務的三個 Pod 使用同一個 group.id order-sync，三個 Partition 各分給一個 Pod，每則訊息只被處理一次，是分工；模式二，同步服務、通知服務、稽核服務使用各自不同的 group.id，每個服務都收到全部訊息，是服務之間的廣播；模式三，Web 服務的每個 Pod 使用含 Pod 名稱的獨立 group.id，例如 cache-web-7f9c-a、cache-web-7f9c-b，每個 Pod 都收到全部訊息，用來清除每個實例的本機快取","\u002Fimages\u002Farticles\u002Fkafka-broadcast.svg",[11,4609,4610,4611,4617],{},"Kafka 沒有像 RabbitMQ Fanout Exchange 那樣「廣播」的設定，",[23,4612,4613,4614,4616],{},"廣播是用 ",[15,4615,4197],{}," 做出來的","。依「誰要收到」分成兩種：",[100,4619,4621],{"id":4620},"不同的服務都要收到各自用不同的-groupid","不同的服務都要收到：各自用不同的 group.id",[11,4623,4624,4625,55],{},"訂單建立後，同步服務要寫進搜尋引擎、通知服務要發推播、稽核服務要留紀錄。三個服務讀同一個 Topic，各自使用自己的 ",[15,4626,4197],{},[36,4628,4631],{"className":4629,"code":4630,"language":41,"meta":44},[39],"order-sync      → 3 個 Pod 分工處理\norder-notify    → 2 個 Pod 分工處理\norder-audit     → 1 個 Pod\n",[15,4632,4630],{"__ignoreMap":44},[11,4634,4635,4636,4639,4640,4642],{},"每個服務都讀到完整的訊息，服務內的多個 Pod 再彼此分工。",[23,4637,4638],{},"Producer 完全不用知道有誰在消費","，之後多一個服務，只要用新的 ",[15,4641,4197],{}," 訂閱就好。",[100,4644,4646],{"id":4645},"同一個服務的每個-pod-都要收到每個-pod-一個-groupid","同一個服務的每個 Pod 都要收到：每個 Pod 一個 group.id",[11,4648,4649,4650,4653],{},"有些事情是",[23,4651,4652],{},"每個實例都要做","的，例如：",[66,4655,4656,4663],{},[69,4657,4658,4659,4662],{},"物件資料更新，每個 Web Pod 的",[23,4660,4661],{},"本機記憶體快取","都要清掉",[69,4664,4665],{},"使用者透過 WebSocket 連在不同的 Pod 上，推播訊息要讓每個 Pod 都收到，再由連著那個使用者的 Pod 送出去",[11,4667,4668,4669,4671,4672,55],{},"這時候如果全部 Pod 共用一個 ",[15,4670,4197],{},"，一則訊息只會被其中一個 Pod 收到，其他 Pod 的快取就永遠是舊的。做法是讓",[23,4673,4674],{},"每個 Pod 有自己的 group.id",[36,4676,4678],{"className":3145,"code":4677,"language":3147,"meta":44,"style":44},"var config = new ConsumerConfig\n{\n    BootstrapServers = \"kafka:9092\",\n    \u002F\u002F 在 K8s 裡 MachineName 就是 Pod 名稱，每個 Pod 自成一個 Group\n    GroupId = $\"cache-invalidate-{Environment.MachineName}\",\n    \u002F\u002F 新 Pod 只需要處理之後的事件，不用從頭讀\n    AutoOffsetReset = AutoOffsetReset.Latest\n};\n",[15,4679,4680,4696,4700,4712,4717,4747,4752,4762],{"__ignoreMap":44},[376,4681,4682,4685,4688,4690,4693],{"class":378,"line":379},[376,4683,4684],{"class":3196},"var",[376,4686,4687],{"class":1020}," config",[376,4689,3313],{"class":3196},[376,4691,4692],{"class":3196}," new",[376,4694,4695],{"class":1020}," ConsumerConfig\n",[376,4697,4698],{"class":378,"line":386},[376,4699,3324],{"class":393},[376,4701,4702,4705,4707,4710],{"class":378,"line":397},[376,4703,4704],{"class":393},"    BootstrapServers ",[376,4706,3240],{"class":3196},[376,4708,4709],{"class":414}," \"kafka:9092\"",[376,4711,3226],{"class":393},[376,4713,4714],{"class":378,"line":405},[376,4715,4716],{"class":382},"    \u002F\u002F 在 K8s 裡 MachineName 就是 Pod 名稱，每個 Pod 自成一個 Group\n",[376,4718,4719,4722,4724,4727,4730,4733,4736,4739,4742,4745],{"class":378,"line":418},[376,4720,4721],{"class":393},"    GroupId ",[376,4723,3240],{"class":3196},[376,4725,4726],{"class":414}," $\"cache-invalidate-",[376,4728,4729],{"class":414},"{",[376,4731,4732],{"class":393},"Environment",[376,4734,4735],{"class":414},".",[376,4737,4738],{"class":393},"MachineName",[376,4740,4741],{"class":414},"}",[376,4743,4744],{"class":414},"\"",[376,4746,3226],{"class":393},[376,4748,4749],{"class":378,"line":429},[376,4750,4751],{"class":382},"    \u002F\u002F 新 Pod 只需要處理之後的事件，不用從頭讀\n",[376,4753,4754,4757,4759],{"class":378,"line":437},[376,4755,4756],{"class":393},"    AutoOffsetReset ",[376,4758,3240],{"class":3196},[376,4760,4761],{"class":393}," AutoOffsetReset.Latest\n",[376,4763,4764],{"class":378,"line":447},[376,4765,4766],{"class":393},"};\n",[11,4768,4769],{},"要注意的地方：",[66,4771,4772,4786,4797],{},[69,4773,4774,4777,4778,4781,4782,4785],{},[23,4775,4776],{},"每次部署都會產生新的 Group","：Pod 名稱每次都不同，舊的 Group 雖然沒人用了，但它的 Offset 紀錄會留在 Kafka，直到 ",[15,4779,4780],{},"offsets.retention.minutes","（預設 7 天）後才清除。功能上沒有影響，但",[23,4783,4784],{},"監控 Consumer Lag 的儀表板會出現一堆停止消費、Lag 一直增加的 Group","，告警規則要排除這個前綴，不然會一直誤報",[69,4787,4788,4789,4792,4793,4796],{},"另一種做法是",[23,4790,4791],{},"不加入 Group","：直接用 ",[15,4794,4795],{},"Assign"," 指定讀取所有 Partition、從尾端開始讀、不提交 Offset。沒有 Group 就沒有 Rebalance，也不會留下多餘的 Offset 紀錄，但新增 Partition 時要自己處理",[69,4798,4799,4800,4803],{},"這種用法本質上是「",[23,4801,4802],{},"收到就做、漏了也沒關係","」：Pod 重啟的那幾秒內發出的訊息它收不到。如果漏掉會出問題，快取一定要再加上 TTL 當作保險",[28,4805,4807],{"id":4806},"二按順序推送key-只是第一步","二、按順序推送：Key 只是第一步",[11,4809,4810,4811,4814],{},"順序是 Kafka 最常被誤解的地方。「用帳號 ID 當 Key」只保證了",[23,4812,4813],{},"同一個帳號的訊息進同一個 Partition","，但從 Producer 到 Consumer 處理完，中間還有好幾個地方會把順序打亂。",[100,4816,4817],{"id":4817},"先決定需要哪一種順序",[132,4819,4820,4833],{},[135,4821,4822],{},[138,4823,4824,4827,4830],{},[141,4825,4826],{},"需求",[141,4828,4829],{},"做法",[141,4831,4832],{},"代價",[152,4834,4835,4849,4863],{},[138,4836,4837,4840,4846],{},[157,4838,4839],{},"全部訊息嚴格依序",[157,4841,4842,4843],{},"Topic 只開 ",[23,4844,4845],{},"1 個 Partition",[157,4847,4848],{},"整個 Topic 只能有一個 Consumer 在處理，吞吐量上限很低",[138,4850,4851,4854,4860],{},[157,4852,4853],{},"同一個實體的訊息依序",[157,4855,4856,4857],{},"以實體 ID（帳號、訂單）當 ",[23,4858,4859],{},"Key",[157,4861,4862],{},"不同實體之間沒有順序，這通常也不需要",[138,4864,4865,4868,4871],{},[157,4866,4867],{},"不需要順序",[157,4869,4870],{},"不設 Key",[157,4872,4873],{},"吞吐量最好",[11,4875,4876],{},"真正需要「全域順序」的情況其實很少，大部分需求仔細想都是「同一個帳號、同一筆訂單內要有序」。",[100,4878,4880],{"id":4879},"producer-端重試可能讓順序對調","Producer 端：重試可能讓順序對調",[11,4882,4883,4884,4887],{},"Producer 會同時送出多個批次（",[15,4885,4886],{},"max.in.flight.requests.per.connection","，預設 5）。如果批次 1 失敗重試、批次 2 先成功了，寫進 Partition 的順序就變成 2、1。",[66,4889,4890,4903,4909],{},[69,4891,4892,4899,4900],{},[23,4893,4894,4895,4898],{},"開啟冪等（",[15,4896,4897],{},"enable.idempotence=true","）","：Broker 會依序號檢查，在 in-flight 不超過 5 的情況下仍然保證順序。Java Client 預設開啟，",[23,4901,4902],{},".NET 的 Confluent.Kafka 預設是關的，要自己打開",[69,4904,4905,4906,4908],{},"如果因為某些原因不能開冪等，就要把 ",[15,4907,4886],{}," 設成 1，代價是吞吐量下降",[69,4910,4911,4914,4915,4918],{},[23,4912,4913],{},"應用程式自己也不能亂","：同一個帳號的事件如果是從多個執行緒同時呼叫 ",[15,4916,4917],{},"Produce","，送出的順序本身就不確定，Kafka 也救不了",[100,4920,4922],{"id":4921},"consumer-端最常見的錯誤是丟進執行緒池","Consumer 端：最常見的錯誤是丟進執行緒池",[11,4924,4925],{},[1952,4926],{"alt":4927,"src":4928},"Kafka Consumer 端順序比較：錯誤做法是 Consumer 從 Partition 依序取出帳號 A001 的下單、改單、取消三則訊息後，丟進執行緒池平行處理，取消可能比下單先完成，順序被打亂；正確做法是依 Key 的雜湊分配到固定的處理通道，A001 的三則訊息都進通道 1 依序處理，B002 的訊息在通道 2 平行處理，同一個 Key 有序，不同 Key 平行","\u002Fimages\u002Farticles\u002Fkafka-ordering.svg",[11,4930,4931,4932,4935,4936,4939],{},"Partition 數量有限，單一 Consumer 處理太慢時，很自然會想「拉回來之後丟給 ",[15,4933,4934],{},"Task.Run"," 平行處理」。但這樣一來，同一個帳號的「下單」和「取消」被兩個執行緒同時處理，",[23,4937,4938],{},"誰先完成完全看運氣","，前面所有保證順序的努力全部白費。",[11,4941,4942,4943,55],{},"需要在 Consumer 內部加速時，要",[23,4944,4945],{},"依 Key 分流",[36,4947,4949],{"className":3145,"code":4948,"language":3147,"meta":44,"style":44},"\u002F\u002F 8 條處理通道，同一個 Key 永遠進同一條，通道內依序處理\nvar lanes = Enumerable.Range(0, 8)\n    .Select(_ => Channel.CreateBounded\u003CConsumeResult\u003Cstring, string>>(100))\n    .ToArray();\n\nwhile (!ct.IsCancellationRequested)\n{\n    var result = consumer.Consume(ct);\n    var lane = (int)((uint)result.Message.Key.GetHashCode() % (uint)lanes.Length);\n    await lanes[lane].Writer.WriteAsync(result, ct);\n}\n",[15,4950,4951,4956,4983,5026,5035,5039,5053,5057,5076,5115,5128],{"__ignoreMap":44},[376,4952,4953],{"class":378,"line":379},[376,4954,4955],{"class":382},"\u002F\u002F 8 條處理通道，同一個 Key 永遠進同一條，通道內依序處理\n",[376,4957,4958,4960,4963,4965,4968,4971,4973,4976,4978,4981],{"class":378,"line":386},[376,4959,4684],{"class":3196},[376,4961,4962],{"class":1020}," lanes",[376,4964,3313],{"class":3196},[376,4966,4967],{"class":393}," Enumerable.",[376,4969,4970],{"class":1020},"Range",[376,4972,3220],{"class":393},[376,4974,4975],{"class":560},"0",[376,4977,3188],{"class":393},[376,4979,4980],{"class":560},"8",[376,4982,3319],{"class":393},[376,4984,4985,4987,4990,4992,4995,4997,5000,5003,5005,5008,5010,5013,5015,5017,5020,5023],{"class":378,"line":397},[376,4986,3170],{"class":393},[376,4988,4989],{"class":1020},"Select",[376,4991,3220],{"class":393},[376,4993,4994],{"class":1020},"_",[376,4996,3246],{"class":3196},[376,4998,4999],{"class":393}," Channel.",[376,5001,5002],{"class":1020},"CreateBounded",[376,5004,3176],{"class":393},[376,5006,5007],{"class":1020},"ConsumeResult",[376,5009,3176],{"class":393},[376,5011,5012],{"class":3196},"string",[376,5014,3188],{"class":393},[376,5016,5012],{"class":3196},[376,5018,5019],{"class":393},">>(",[376,5021,5022],{"class":560},"100",[376,5024,5025],{"class":393},"))\n",[376,5027,5028,5030,5033],{"class":378,"line":405},[376,5029,3170],{"class":393},[376,5031,5032],{"class":1020},"ToArray",[376,5034,3532],{"class":393},[376,5036,5037],{"class":378,"line":418},[376,5038,1036],{"emptyLinePlaceholder":1035},[376,5040,5041,5044,5047,5050],{"class":378,"line":429},[376,5042,5043],{"class":3196},"while",[376,5045,5046],{"class":393}," (",[376,5048,5049],{"class":3196},"!",[376,5051,5052],{"class":393},"ct.IsCancellationRequested)\n",[376,5054,5055],{"class":378,"line":437},[376,5056,3324],{"class":393},[376,5058,5059,5062,5065,5067,5070,5073],{"class":378,"line":447},[376,5060,5061],{"class":3196},"    var",[376,5063,5064],{"class":1020}," result",[376,5066,3313],{"class":3196},[376,5068,5069],{"class":393}," consumer.",[376,5071,5072],{"class":1020},"Consume",[376,5074,5075],{"class":393},"(ct);\n",[376,5077,5078,5080,5083,5085,5087,5090,5093,5096,5099,5102,5105,5108,5110,5112],{"class":378,"line":531},[376,5079,5061],{"class":3196},[376,5081,5082],{"class":1020}," lane",[376,5084,3313],{"class":3196},[376,5086,5046],{"class":393},[376,5088,5089],{"class":3196},"int",[376,5091,5092],{"class":393},")((",[376,5094,5095],{"class":3196},"uint",[376,5097,5098],{"class":393},")result.Message.Key.",[376,5100,5101],{"class":1020},"GetHashCode",[376,5103,5104],{"class":393},"() ",[376,5106,5107],{"class":3196},"%",[376,5109,5046],{"class":393},[376,5111,5095],{"class":3196},[376,5113,5114],{"class":393},")lanes.Length);\n",[376,5116,5117,5119,5122,5125],{"class":378,"line":542},[376,5118,3329],{"class":3196},[376,5120,5121],{"class":393}," lanes[lane].Writer.",[376,5123,5124],{"class":1020},"WriteAsync",[376,5126,5127],{"class":393},"(result, ct);\n",[376,5129,5130],{"class":378,"line":552},[376,5131,3381],{"class":393},[11,5133,5134,5135,5138,5139,5142],{},"這樣同一個帳號依序處理、不同帳號平行處理。但要注意 ",[23,5136,5137],{},"Offset 不能隨便提交","：通道 1 處理到 Offset 105、通道 2 才處理到 98，這時只能提交 98，否則當掉重啟後 99～104 就漏掉了。這部分容易寫錯，先確認",[23,5140,5141],{},"增加 Partition 與 Consumer"," 能不能解決，再考慮自己做。",[100,5144,5145],{"id":5145},"失敗重試會卡住後面所有訊息",[11,5147,5148],{},"有順序的 Partition 裡，一則訊息處理失敗時只有兩個選擇：",[66,5150,5151,5157],{},[69,5152,5153,5156],{},[23,5154,5155],{},"原地重試","：後面的訊息全部等它，這個 Partition 停住，Lag 一直上升（Head-of-line Blocking）",[69,5158,5159,5162],{},[23,5160,5161],{},"丟到另一個重試 Topic、先處理後面的","：Partition 不會卡住，但這個帳號的順序就被打亂了",[11,5164,5165,5166,5169],{},"沒有兩全其美的做法。交易類的事件通常選擇",[23,5167,5168],{},"原地重試、加上退避時間，並且一定要對 Lag 告警","，讓工程師盡快介入；如果業務允許，也可以只把「同一個 Key 後續的訊息」一起暫存起來，其他 Key 繼續處理，但實作複雜度高很多。",[100,5171,5173],{"id":5172},"增加-partition-會打亂-key-的對應","增加 Partition 會打亂 Key 的對應",[11,5175,5176,5179,5180,5183],{},[15,5177,5178],{},"hash(key) % N"," 的 N 改變後，同一個帳號會被分到不同的 Partition。擴充的那一刻，新 Partition 上的新訊息可能比舊 Partition 上還沒處理完的舊訊息先被處理。順序敏感的 Topic ",[23,5181,5182],{},"一開始就要開足 Partition","，非擴充不可時，要先停止寫入、等 Consumer 把 Lag 消化完再擴。",[28,5185,5187],{"id":5186},"三相同-groupid-會發生什麼事","三、相同 group.id 會發生什麼事",[11,5189,5190,5192],{},[15,5191,4197],{}," 只是一個字串，Kafka 不會檢查「這兩個 Consumer 是不是同一個服務」。只要字串一樣，就會被當成同一個 Group 一起分工。這帶來幾個常見事故：",[100,5194,5196],{"id":5195},"兩個不同的服務用了同一個-groupid","兩個不同的服務用了同一個 group.id",[11,5198,5199,5200,5202,5203,55],{},"通知服務是從同步服務複製過來改的，設定檔裡的 ",[15,5201,4197],{}," 忘了改。兩個服務都叫 ",[15,5204,5205],{},"order-sync",[66,5207,5208,5214],{},[69,5209,5210,5211],{},"6 個 Partition 被分給兩個服務的所有 Pod，",[23,5212,5213],{},"同步服務只拿到一部分 Partition，通知服務拿到另一部分",[69,5215,5216,5217,5220],{},"結果就是搜尋引擎少了一部分訂單、一部分使用者沒收到通知，而且",[23,5218,5219],{},"沒有任何錯誤訊息","，兩邊的 Log 看起來都正常",[11,5222,5223,5224,5226],{},"這和 RabbitMQ 中兩個服務共用同一個 Queue 是完全一樣的問題（參考 ",[119,5225,3916],{"href":3915},"）。",[100,5228,5230],{"id":5229},"本機開發連到正式環境的-kafka","本機開發連到正式環境的 Kafka",[11,5232,5233,5234,5237,5238,5240,5241,5244],{},"工程師在自己的電腦上 Debug，",[15,5235,5236],{},"appsettings.Development.json"," 不小心指到了正式環境的 Kafka，",[15,5239,4197],{}," 又和正式環境一樣。",[23,5242,5243],{},"這台筆電就加入了正式環境的 Group，分走了幾個 Partition","：正式環境的部分訂單被筆電上的程式「處理」掉了，Offset 也被提交，正式環境的 Pod 再也不會處理這些訊息。",[100,5246,5248],{"id":5247},"同一個-groupid-訂閱不同的-topic","同一個 group.id 訂閱不同的 Topic",[11,5250,5251,5252,5255,5256,5258,5259,114],{},"服務 A 用 ",[15,5253,5254],{},"group-x"," 訂閱 Topic 1、服務 B 也用 ",[15,5257,5254],{}," 訂閱 Topic 2。雖然讀的資料不重疊，但它們是同一個 Group：",[23,5260,5261],{},"服務 A 部署、重啟時觸發的 Rebalance，也會讓服務 B 一起暫停",[100,5263,5264],{"id":5264},"怎麼避免",[66,5266,5267,5285,5290,5296],{},[69,5268,5269,5271,5272,5275,5276,2276,5279,1540,5282],{},[15,5270,4197],{}," 命名固定格式：",[15,5273,5274],{},"{服務名稱}-{用途}","，例如 ",[15,5277,5278],{},"order-sync-es",[15,5280,5281],{},"order-notify-push",[23,5283,5284],{},"一個服務的一個用途就是一個 Group",[69,5286,5287,5289],{},[15,5288,4197],{}," 不放在可以隨意複製的共用設定檔裡，而是和服務名稱綁在一起產生",[69,5291,5292,5293],{},"正式環境的 Kafka 開啟驗證與 ACL，",[23,5294,5295],{},"開發環境的帳號根本沒有權限加入正式環境的 Group",[69,5297,5298,5299,5302],{},"定期用 ",[15,5300,5301],{},"kafka-consumer-groups.sh --describe"," 檢查每個 Group 的成員，看到不認識的 Client ID 或主機名稱就要追查",[28,5304,5306],{"id":5305},"四k8s-自動擴展pod-變多了為什麼沒有變快","四、K8s 自動擴展：Pod 變多了，為什麼沒有變快？",[11,5308,5309],{},[1952,5310],{"alt":5311,"src":5312},"Kafka Consumer 在 K8s 自動擴展的比較：左邊用 CPU 指標的 HPA，Topic 有 6 個 Partition，HPA 把 Consumer 擴到 12 個 Pod，只有 6 個分到 Partition，另外 6 個閒置，平均 CPU 因此被拉低，HPA 又縮回去，每次擴縮都觸發 Rebalance，消費反而更慢；右邊用 KEDA 依 Consumer Lag 擴展，最大副本數上限等於 Partition 數 6，Lag 超過門檻才擴、消化完再縮，每個 Pod 都有 Partition 可以處理","\u002Fimages\u002Farticles\u002Fkafka-k8s-autoscaling.svg",[11,5314,5315],{},"這是一個很容易發生的情境：",[66,5317,5318,5327,5330],{},[69,5319,5320,5321,5323,5324],{},"Topic ",[15,5322,4451],{}," 有 ",[23,5325,5326],{},"6 個 Partition",[69,5328,5329],{},"Consumer 服務部署在 K8s，設定了 HPA：CPU 使用率 70%、最少 3 個、最多 20 個 Pod",[69,5331,5332],{},"某天流量暴增，Lag 一路上升，HPA 也如預期把 Pod 擴到了 12 個",[11,5334,5335,5336,5339,5340,114],{},"但 Lag 完全沒有下降得比較快。原因就是第一條規則：",[23,5337,5338],{},"一個 Partition 只能分給 Group 內的一個 Consumer","。6 個 Partition 最多只有 6 個 Pod 在工作，",[23,5341,5342],{},"另外 6 個 Pod 只是佔著資源、什麼都沒做",[11,5344,5345],{},"從監控上看起來是「有 12 個 Pod 在扛流量，很 HA」，實際上一半的 Pod 是閒置的。而且情況還會更糟：",[66,5347,5348,5354,5363],{},[69,5349,5350,5353],{},[23,5351,5352],{},"閒置的 Pod 把平均 CPU 拉低","：6 個工作中的 Pod 都滿載，加上 6 個閒置的，平均 CPU 只有一半，HPA 判斷「不需要這麼多」又開始縮減",[69,5355,5356,5359,5360],{},[23,5357,5358],{},"每次擴展、縮減都會觸發 Rebalance","：Pod 加入或離開 Group，Partition 就要重新分配，分配期間相關的 Partition 停止消費。HPA 來回擴縮，Group 就一直在 Rebalance，",[23,5361,5362],{},"實際消費的時間反而變少",[69,5364,5365,5368],{},[23,5366,5367],{},"CPU 可能根本不是瓶頸","：如果 Consumer 慢是因為寫資料庫很慢，Pod 都在等 I\u002FO，CPU 很低，HPA 完全不會擴展，Lag 卻一直累積",[100,5370,5371],{"id":5371},"正確的做法",[11,5373,5374],{},[23,5375,5376],{},"1. 最大副本數不要超過 Partition 數",[11,5378,5379],{},"這是最基本的一條。想要更多 Consumer 平行處理，要先增加 Partition（並考慮前面講的順序問題），而不是只調 HPA 的上限。",[11,5381,5382],{},[23,5383,5384],{},"2. 依 Consumer Lag 擴展，而不是 CPU",[11,5386,5387,5388,5391,5392,5397],{},"Consumer 該不該擴展，真正的指標是「",[23,5389,5390],{},"還有多少訊息沒處理","」。用 ",[119,5393,5396],{"href":5394,"rel":5395},"https:\u002F\u002Fkeda.sh",[3057],"KEDA"," 的 Kafka Scaler，可以直接依 Group 的 Lag 擴展：",[36,5399,5401],{"className":370,"code":5400,"language":372,"meta":44,"style":44},"apiVersion: keda.sh\u002Fv1alpha1\nkind: ScaledObject\nmetadata:\n  name: order-consumer\nspec:\n  scaleTargetRef:\n    name: order-consumer          # 對應的 Deployment\n  minReplicaCount: 2\n  maxReplicaCount: 6              # 不超過 Partition 數\n  cooldownPeriod: 300             # Lag 消化完後等 5 分鐘再縮，避免來回擴縮\n  triggers:\n    - type: kafka\n      metadata:\n        bootstrapServers: kafka:9092\n        consumerGroup: order-processor\n        topic: order-events\n        lagThreshold: \"500\"       # 平均每個 Pod 負責 500 筆 Lag\n",[15,5402,5403,5412,5421,5427,5436,5442,5448,5460,5469,5482,5495,5502,5513,5520,5530,5540,5550],{"__ignoreMap":44},[376,5404,5405,5407,5409],{"class":378,"line":379},[376,5406,472],{"class":389},[376,5408,411],{"class":393},[376,5410,5411],{"class":414},"keda.sh\u002Fv1alpha1\n",[376,5413,5414,5416,5418],{"class":378,"line":386},[376,5415,482],{"class":389},[376,5417,411],{"class":393},[376,5419,5420],{"class":414},"ScaledObject\n",[376,5422,5423,5425],{"class":378,"line":397},[376,5424,492],{"class":389},[376,5426,394],{"class":393},[376,5428,5429,5431,5433],{"class":378,"line":405},[376,5430,499],{"class":389},[376,5432,411],{"class":393},[376,5434,5435],{"class":414},"order-consumer\n",[376,5437,5438,5440],{"class":378,"line":418},[376,5439,509],{"class":389},[376,5441,394],{"class":393},[376,5443,5444,5446],{"class":378,"line":429},[376,5445,516],{"class":389},[376,5447,394],{"class":393},[376,5449,5450,5452,5454,5457],{"class":378,"line":437},[376,5451,545],{"class":389},[376,5453,411],{"class":393},[376,5455,5456],{"class":414},"order-consumer",[376,5458,5459],{"class":382},"          # 對應的 Deployment\n",[376,5461,5462,5465,5467],{"class":378,"line":447},[376,5463,5464],{"class":389},"  minReplicaCount",[376,5466,411],{"class":393},[376,5468,561],{"class":560},[376,5470,5471,5474,5476,5479],{"class":378,"line":531},[376,5472,5473],{"class":389},"  maxReplicaCount",[376,5475,411],{"class":393},[376,5477,5478],{"class":560},"6",[376,5480,5481],{"class":382},"              # 不超過 Partition 數\n",[376,5483,5484,5487,5489,5492],{"class":378,"line":542},[376,5485,5486],{"class":389},"  cooldownPeriod",[376,5488,411],{"class":393},[376,5490,5491],{"class":560},"300",[376,5493,5494],{"class":382},"             # Lag 消化完後等 5 分鐘再縮，避免來回擴縮\n",[376,5496,5497,5500],{"class":378,"line":552},[376,5498,5499],{"class":389},"  triggers",[376,5501,394],{"class":393},[376,5503,5504,5506,5508,5510],{"class":378,"line":564},[376,5505,586],{"class":393},[376,5507,589],{"class":389},[376,5509,411],{"class":393},[376,5511,5512],{"class":414},"kafka\n",[376,5514,5515,5518],{"class":378,"line":575},[376,5516,5517],{"class":389},"      metadata",[376,5519,394],{"class":393},[376,5521,5522,5525,5527],{"class":378,"line":583},[376,5523,5524],{"class":389},"        bootstrapServers",[376,5526,411],{"class":393},[376,5528,5529],{"class":414},"kafka:9092\n",[376,5531,5532,5535,5537],{"class":378,"line":597},[376,5533,5534],{"class":389},"        consumerGroup",[376,5536,411],{"class":393},[376,5538,5539],{"class":414},"order-processor\n",[376,5541,5542,5545,5547],{"class":378,"line":605},[376,5543,5544],{"class":389},"        topic",[376,5546,411],{"class":393},[376,5548,5549],{"class":414},"order-events\n",[376,5551,5552,5555,5557,5560],{"class":378,"line":616},[376,5553,5554],{"class":389},"        lagThreshold",[376,5556,411],{"class":393},[376,5558,5559],{"class":414},"\"500\"",[376,5561,5562],{"class":382},"       # 平均每個 Pod 負責 500 筆 Lag\n",[11,5564,5565,5566,5569,5570,5576,5577,5580],{},"KEDA 的 Kafka Scaler 有一個 ",[15,5567,5568],{},"allowIdleConsumers"," 設定，",[23,5571,5572,5573],{},"預設是 ",[15,5574,5575],{},"false","，代表算出來的副本數不會超過 Partition 數，即使 ",[15,5578,5579],{},"maxReplicaCount"," 設得比較大也一樣，等於內建了第一條規則。",[11,5582,5583],{},[23,5584,5585],{},"3. 減少 Rebalance 的衝擊",[66,5587,5588,5597,5614],{},[69,5589,5590,55,5593,5596],{},[23,5591,5592],{},"使用漸進式 Rebalance",[15,5594,5595],{},"partition.assignment.strategy=cooperative-sticky","，Pod 加入或離開時只搬動需要移動的 Partition，其他 Pod 照常消費",[69,5598,5599,5602,5603,5606,5607,5610,5611,5613],{},[23,5600,5601],{},"優雅關閉","：K8s 送出 SIGTERM 時，Consumer 要主動呼叫 ",[15,5604,5605],{},"Close()"," 離開 Group 並提交 Offset。否則 Group 要等 ",[15,5608,5609],{},"session.timeout.ms","（預設 45 秒）才發現這個 Pod 不見了，這段時間它負責的 Partition 完全沒人處理。",[15,5612,3859],{}," 也要大於處理一批訊息的時間",[69,5615,5616,5619,5620,5623,5624,5627,5628,5630,5631,5634],{},[23,5617,5618],{},"Static Membership","：設定 ",[15,5621,5622],{},"group.instance.id","（.NET 是 ",[15,5625,5626],{},"GroupInstanceId","）給每個 Consumer 一個固定身分，搭配 StatefulSet 固定的 Pod 名稱。Pod 重啟只要在 ",[15,5629,5609],{}," 內回來，就拿回原本的 Partition，",[23,5632,5633],{},"完全不會觸發 Rebalance","，很適合滾動更新頻繁的服務",[36,5636,5638],{"className":3145,"code":5637,"language":3147,"meta":44,"style":44},"protected override Task ExecuteAsync(CancellationToken stoppingToken) => Task.Run(() =>\n{\n    _consumer.Subscribe(\"order-events\");\n    try\n    {\n        while (!stoppingToken.IsCancellationRequested)\n        {\n            var result = _consumer.Consume(stoppingToken);\n            Handle(result);\n            _consumer.Commit(result);\n        }\n    }\n    catch (OperationCanceledException) { }\n    finally\n    {\n        \u002F\u002F 收到 SIGTERM 時主動離開 Group，讓 Partition 立刻交給其他 Pod，而不是等 45 秒逾時\n        \u002F\u002F Consumer 不是執行緒安全的，Close 要在同一個執行緒呼叫\n        _consumer.Close();\n    }\n}, stoppingToken);\n",[15,5639,5640,5676,5680,5695,5700,5705,5717,5722,5739,5747,5757,5762,5767,5780,5785,5789,5794,5799,5809,5813],{"__ignoreMap":44},[376,5641,5642,5645,5648,5650,5653,5655,5657,5660,5663,5666,5669,5672,5674],{"class":378,"line":379},[376,5643,5644],{"class":3196},"protected",[376,5646,5647],{"class":3196}," override",[376,5649,3280],{"class":1020},[376,5651,5652],{"class":1020}," ExecuteAsync",[376,5654,3220],{"class":393},[376,5656,3307],{"class":1020},[376,5658,5659],{"class":1020}," stoppingToken",[376,5661,5662],{"class":393},") ",[376,5664,5665],{"class":3196},"=>",[376,5667,5668],{"class":393}," Task.",[376,5670,5671],{"class":1020},"Run",[376,5673,3775],{"class":393},[376,5675,3778],{"class":3196},[376,5677,5678],{"class":378,"line":386},[376,5679,3324],{"class":393},[376,5681,5682,5685,5688,5690,5693],{"class":378,"line":397},[376,5683,5684],{"class":393},"    _consumer.",[376,5686,5687],{"class":1020},"Subscribe",[376,5689,3220],{"class":393},[376,5691,5692],{"class":414},"\"order-events\"",[376,5694,3376],{"class":393},[376,5696,5697],{"class":378,"line":405},[376,5698,5699],{"class":3196},"    try\n",[376,5701,5702],{"class":378,"line":418},[376,5703,5704],{"class":393},"    {\n",[376,5706,5707,5710,5712,5714],{"class":378,"line":429},[376,5708,5709],{"class":3196},"        while",[376,5711,5046],{"class":393},[376,5713,5049],{"class":3196},[376,5715,5716],{"class":393},"stoppingToken.IsCancellationRequested)\n",[376,5718,5719],{"class":378,"line":437},[376,5720,5721],{"class":393},"        {\n",[376,5723,5724,5727,5729,5731,5734,5736],{"class":378,"line":447},[376,5725,5726],{"class":3196},"            var",[376,5728,5064],{"class":1020},[376,5730,3313],{"class":3196},[376,5732,5733],{"class":393}," _consumer.",[376,5735,5072],{"class":1020},[376,5737,5738],{"class":393},"(stoppingToken);\n",[376,5740,5741,5744],{"class":378,"line":531},[376,5742,5743],{"class":1020},"            Handle",[376,5745,5746],{"class":393},"(result);\n",[376,5748,5749,5752,5755],{"class":378,"line":542},[376,5750,5751],{"class":393},"            _consumer.",[376,5753,5754],{"class":1020},"Commit",[376,5756,5746],{"class":393},[376,5758,5759],{"class":378,"line":552},[376,5760,5761],{"class":393},"        }\n",[376,5763,5764],{"class":378,"line":564},[376,5765,5766],{"class":393},"    }\n",[376,5768,5769,5772,5774,5777],{"class":378,"line":575},[376,5770,5771],{"class":3196},"    catch",[376,5773,5046],{"class":393},[376,5775,5776],{"class":1020},"OperationCanceledException",[376,5778,5779],{"class":393},") { }\n",[376,5781,5782],{"class":378,"line":583},[376,5783,5784],{"class":3196},"    finally\n",[376,5786,5787],{"class":378,"line":597},[376,5788,5704],{"class":393},[376,5790,5791],{"class":378,"line":605},[376,5792,5793],{"class":382},"        \u002F\u002F 收到 SIGTERM 時主動離開 Group，讓 Partition 立刻交給其他 Pod，而不是等 45 秒逾時\n",[376,5795,5796],{"class":378,"line":616},[376,5797,5798],{"class":382},"        \u002F\u002F Consumer 不是執行緒安全的，Close 要在同一個執行緒呼叫\n",[376,5800,5801,5804,5807],{"class":378,"line":624},[376,5802,5803],{"class":393},"        _consumer.",[376,5805,5806],{"class":1020},"Close",[376,5808,3532],{"class":393},[376,5810,5811],{"class":378,"line":635},[376,5812,5766],{"class":393},[376,5814,5816],{"class":378,"line":5815},20,[376,5817,5818],{"class":393},"}, stoppingToken);\n",[100,5820,5822],{"id":5821},"那-ha-呢","那 HA 呢？",[11,5824,5825,5826,5829],{},"多開幾個閒置的 Pod 當「備援」，其實沒有想像中有用：Kafka 的容錯本來就是透過 Rebalance，",[23,5827,5828],{},"任何一個還活著的 Consumer 都能接手掛掉的那個的 Partition","，不需要專門待命的 Pod。",[11,5831,5832],{},"Consumer 的高可用要做的是：",[66,5834,5835,5849,5852],{},[69,5836,5837,5838,5841,5842,5844,5845,5848],{},"至少 ",[23,5839,5840],{},"2 個以上","的副本，而且用 ",[15,5843,992],{}," 或 Pod Anti-Affinity ",[23,5846,5847],{},"分散在不同的 Node","，避免一台 Node 掛掉就全部消失",[69,5850,5851],{},"副本數不超過 Partition 數，每個 Pod 都在工作",[69,5853,5854],{},"做好優雅關閉，讓計畫中的重啟能立刻交接，而不是等逾時",[28,5856,1890],{"id":1890},[11,5858,5859,5860,5862,5863,5865],{},"廣播靠的是不同的 ",[15,5861,4197],{},"，服務之間各用一個，需要每個 Pod 都收到就每個 Pod 一個；順序要從 Key、Producer 的冪等、Consumer 不丟進執行緒池一路顧到底；",[15,5864,4197],{}," 一樣就會被當成同一組分工，命名與權限都要管好；而 Consumer 在 K8s 上擴展，副本數超過 Partition 數只會多出閒置的 Pod 和更多的 Rebalance，要依 Lag 擴展、上限對齊 Partition 數，才是真的有在做事的擴展。",[1132,5867,5868],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":44,"searchDepth":386,"depth":386,"links":5870},[5871,5875,5882,5888,5892],{"id":4600,"depth":386,"text":4601,"children":5872},[5873,5874],{"id":4620,"depth":397,"text":4621},{"id":4645,"depth":397,"text":4646},{"id":4806,"depth":386,"text":4807,"children":5876},[5877,5878,5879,5880,5881],{"id":4817,"depth":397,"text":4817},{"id":4879,"depth":397,"text":4880},{"id":4921,"depth":397,"text":4922},{"id":5145,"depth":397,"text":5145},{"id":5172,"depth":397,"text":5173},{"id":5186,"depth":386,"text":5187,"children":5883},[5884,5885,5886,5887],{"id":5195,"depth":397,"text":5196},{"id":5229,"depth":397,"text":5230},{"id":5247,"depth":397,"text":5248},{"id":5264,"depth":397,"text":5264},{"id":5305,"depth":386,"text":5306,"children":5889},[5890,5891],{"id":5371,"depth":397,"text":5371},{"id":5821,"depth":397,"text":5822},{"id":1890,"depth":386,"text":1890},"2025-07-15","Kafka 的基本元件可以先參考 Kafka 完整入門。這篇只講一件事背後的各種變化：group.id 與 Partition 的分配規則。幾乎所有 Kafka 在應用上的坑，最後都會回到這兩條規則：",{},{"title":4569,"description":5894},"articles\u002Fkafka-broadcast-ordering-k8s-scaling",[4029,1163,5396,5899,3039],"分散式系統","zR2c786IQQHxXa2uP6UMmvX-5TSx3Lue-0pnzwOl8is",{"id":5902,"title":5903,"body":5904,"date":6883,"description":44,"excerpt":1156,"extension":1157,"meta":6884,"navigation":1035,"path":3924,"seo":6885,"stem":6886,"tags":6887,"__hash__":6889},"articles\u002Farticles\u002Fkafka-topic-partition-consumer-group.md","Kafka 完整入門：Broker、Topic、Partition、Replica、Consumer Group 一次搞懂",{"type":8,"value":5905,"toc":6860},[5906,5910,5916,5935,5938,5941,6055,6066,6070,6076,6099,6102,6106,6109,6125,6128,6132,6141,6155,6161,6209,6216,6220,6226,6236,6263,6270,6283,6287,6290,6296,6352,6368,6374,6381,6385,6396,6399,6402,6428,6432,6439,6478,6481,6487,6495,6500,6503,6510,6513,6518,6525,6531,6535,6542,6553,6567,6573,6576,6598,6608,6614,6668,6672,6677,6691,6694,6710,6720,6724,6727,6754,6758,6761,6802,6805,6809,6816,6847,6849,6857],[28,5907,5909],{"id":5908},"kafka-是一份分散式日誌","Kafka 是一份分散式日誌",[11,5911,5912,5913,55],{},"理解 Kafka 最好的方式，是把它想成一份",[23,5914,5915],{},"只能往後附加（Append-only）的日誌檔",[66,5917,5918,5925,5932],{},[69,5919,5920,5921,5924],{},"Producer 寫入的訊息，一律",[23,5922,5923],{},"附加在尾端","，寫入後不能修改",[69,5926,5927,5928,5931],{},"訊息被讀取後",[23,5929,5930],{},"不會刪除","，而是依保留期限（例如 7 天）或容量上限才清除",[69,5933,5934],{},"每個讀取者自己記住「我讀到第幾筆」，想從頭重讀、從昨天重讀都可以",[11,5936,5937],{},"這和「訊息被取走就消失」的傳統佇列有本質上的不同，後面所有的設計——Partition、Offset、Consumer Group——都是從這個模型延伸出來的。",[28,5939,5940],{"id":5940},"先認識所有元件",[132,5942,5943,5953],{},[135,5944,5945],{},[138,5946,5947,5950],{},[141,5948,5949],{},"元件",[141,5951,5952],{},"是什麼",[152,5954,5955,5965,5975,5985,6002,6012,6022,6032,6042],{},[138,5956,5957,5962],{},[157,5958,5959],{},[23,5960,5961],{},"Broker",[157,5963,5964],{},"一台 Kafka 伺服器，負責儲存 Partition 的資料、處理讀寫請求",[138,5966,5967,5972],{},[157,5968,5969],{},[23,5970,5971],{},"Cluster",[157,5973,5974],{},"多台 Broker 組成的叢集，資料與負載分散在各台 Broker 上",[138,5976,5977,5982],{},[157,5978,5979],{},[23,5980,5981],{},"Controller",[157,5983,5984],{},"叢集的管理者，負責記錄 Topic 的設定、決定每個 Partition 由誰當 Leader、處理 Broker 上下線",[138,5986,5987,5992],{},[157,5988,5989],{},[23,5990,5991],{},"Topic",[157,5993,5994,5995,2276,5998,6001],{},"一類訊息的名稱，例如 ",[15,5996,5997],{},"orders",[15,5999,6000],{},"app-logs","，是邏輯上的分類",[138,6003,6004,6009],{},[157,6005,6006],{},[23,6007,6008],{},"Partition",[157,6010,6011],{},"Topic 被切成的多份日誌，是平行處理與順序保證的單位",[138,6013,6014,6019],{},[157,6015,6016],{},[23,6017,6018],{},"Replica",[157,6020,6021],{},"每個 Partition 在不同 Broker 上的副本，分成一個 Leader 與多個 Follower",[138,6023,6024,6029],{},[157,6025,6026],{},[23,6027,6028],{},"Producer",[157,6030,6031],{},"寫入訊息的程式",[138,6033,6034,6039],{},[157,6035,6036],{},[23,6037,6038],{},"Consumer",[157,6040,6041],{},"讀取訊息的程式",[138,6043,6044,6049],{},[157,6045,6046],{},[23,6047,6048],{},"Consumer Group",[157,6050,6051,6052,6054],{},"一群共同分工消費的 Consumer，用 ",[15,6053,4197],{}," 識別",[11,6056,6057,6058,6061,6062,6065],{},"早期的 Kafka 需要另外架一組 ZooKeeper 來存放叢集的中繼資料、選出 Controller。從 2.8 開始可以改用 ",[23,6059,6060],{},"KRaft"," 模式，由 Kafka 自己內建的 Raft 共識機制管理；今年三月發布的 ",[23,6063,6064],{},"Kafka 4.0 已經完全移除 ZooKeeper","，新的叢集直接用 KRaft 就好，少維護一整組服務。",[28,6067,6069],{"id":6068},"topic-與-partition","Topic 與 Partition",[11,6071,6072],{},[1952,6073],{"alt":6074,"src":6075},"Kafka Topic 與 Partition 示意圖：Producer 以 hash(key) % 3 決定 Partition，key 為帳號 A001 的訊息寫入 Partition 1 的尾端；Topic orders 有三個 Partition，每個 Partition 的 offset 各自從 0 開始編號，新訊息只會附加在尾端；同一個 Partition 內依 offset 嚴格有序，不同 Partition 之間沒有順序保證，同一個 Key 永遠進同一個 Partition，因此同帳號的事件有序；訊息被讀取後不會刪除，依保留期限或大小清除，不同的 Consumer Group 可以各自從任何 offset 重讀","\u002Fimages\u002Farticles\u002Fkafka-topic-partition.svg",[66,6077,6078,6089],{},[69,6079,6080,6082,6083,1540,6085,6088],{},[23,6081,5991],{}," 實際上被切成多個 ",[23,6084,6008],{},[23,6086,6087],{},"每個 Partition 才是一份獨立的日誌","。Partition 分散在叢集中不同的 Broker 上，讀寫的負載也就分散開來",[69,6090,6091,6094,6095,6098],{},[23,6092,6093],{},"Offset"," 是訊息在 Partition 中的位置編號，每個 Partition ",[23,6096,6097],{},"各自從 0 開始","遞增",[11,6100,6101],{},"Partition 是 Kafka 能夠水平擴展的根本：一個 Topic 有 12 個 Partition，就可以分散在多台 Broker 上同時寫入、同時讀取。",[100,6103,6105],{"id":6104},"順序只在-partition-內成立","順序只在 Partition 內成立",[11,6107,6108],{},"這是使用 Kafka 最重要的一件事：",[66,6110,6111,6120],{},[69,6112,6113,6116,6117],{},[23,6114,6115],{},"同一個 Partition 內","，訊息依 Offset ",[23,6118,6119],{},"嚴格有序",[69,6121,6122],{},[23,6123,6124],{},"不同 Partition 之間沒有順序保證",[11,6126,6127],{},"如果 Topic 有 3 個 Partition，先寫入的訊息 A 在 P0、後寫入的訊息 B 在 P1，消費端完全有可能先處理 B 再處理 A。",[100,6129,6131],{"id":6130},"key-決定訊息進哪個-partition","Key 決定訊息進哪個 Partition",[11,6133,6134,6135,6137,6138,55],{},"Producer 寫入時可以帶一個 ",[23,6136,4859],{},"，預設的分區策略是 ",[15,6139,6140],{},"hash(key) % Partition 數量",[66,6142,6143,6149],{},[69,6144,6145,6148],{},[23,6146,6147],{},"相同 Key 的訊息，一定進同一個 Partition","，因此彼此之間是有序的",[69,6150,6151,6154],{},[23,6152,6153],{},"沒有 Key"," 的訊息會被分散到各個 Partition（新版本的預設是 Sticky Partitioner，先把一批訊息送到同一個 Partition 再換下一個），吞吐量最好，但沒有任何順序保證",[11,6156,6157,6158,804],{},"所以 Key 的選擇，就是在決定「",[23,6159,6160],{},"哪些訊息之間需要有序",[132,6162,6163,6175],{},[135,6164,6165],{},[138,6166,6167,6169,6172],{},[141,6168,4859],{},[141,6170,6171],{},"保證",[141,6173,6174],{},"適合",[152,6176,6177,6188,6199],{},[138,6178,6179,6182,6185],{},[157,6180,6181],{},"帳號 ID",[157,6183,6184],{},"同一個帳號的事件有序",[157,6186,6187],{},"委託、成交、帳務異動",[138,6189,6190,6193,6196],{},[157,6191,6192],{},"訂單 ID",[157,6194,6195],{},"同一筆訂單的狀態變化有序",[157,6197,6198],{},"訂單建立 → 付款 → 出貨",[138,6200,6201,6203,6206],{},[157,6202,4870],{},[157,6204,6205],{},"不保證順序，負載最平均",[157,6207,6208],{},"日誌、監控指標、點擊紀錄",[11,6210,6211,6212,6215],{},"Key 也要注意",[23,6213,6214],{},"分布是否平均","：如果用「商品類別」當 Key，而某個類別佔了八成的訊息，就會有一個 Partition 特別忙（Hot Partition），其他 Partition 閒置，整個 Topic 的吞吐量被那一個 Partition 卡住。",[28,6217,6219],{"id":6218},"replicabroker-掛掉資料也不會掉","Replica：Broker 掛掉資料也不會掉",[11,6221,6222],{},[1952,6223],{"alt":6224,"src":6225},"Kafka 複本示意圖：三台 Broker，orders 的 P0 Leader 在 Broker 1、Follower 在 Broker 2 與 3；P1 Leader 在 Broker 2；P2 Leader 在 Broker 3，Leader 平均分散；Producer 與 Consumer 只和 Leader 讀寫，Follower 持續向 Leader 複製；與 Leader 保持同步的副本集合稱為 ISR；設定 replication.factor=3、min.insync.replicas=2、acks=all 時，訊息要寫進至少兩個同步副本才算成功，可以容忍一台 Broker 故障；Leader 所在的 Broker 掛掉時，Controller 從 ISR 中選出新的 Leader","\u002Fimages\u002Farticles\u002Fkafka-replication.svg",[11,6227,6228,6229,6232,6233,55],{},"每個 Partition 可以有多個副本，數量由 ",[15,6230,6231],{},"replication.factor"," 決定，正式環境通常設 ",[23,6234,6235],{},"3",[66,6237,6238,6247,6253],{},[69,6239,6240,6243,6244],{},[23,6241,6242],{},"Leader","：每個 Partition 只有一個 Leader，",[23,6245,6246],{},"所有讀寫都經過 Leader",[69,6248,6249,6252],{},[23,6250,6251],{},"Follower","：持續從 Leader 複製資料，平常不對外服務，Leader 掛掉時接手",[69,6254,6255,6258,6259,6262],{},[23,6256,6257],{},"ISR（In-Sync Replicas）","：跟 Leader 保持同步、沒有落後太多的副本集合。Follower 太久沒跟上（",[15,6260,6261],{},"replica.lag.time.max.ms","），就會被踢出 ISR",[11,6264,6265,6266,6269],{},"Leader 所在的 Broker 掛掉時，Controller 會從 ",[23,6267,6268],{},"ISR"," 中選一個 Follower 成為新的 Leader，Producer 和 Consumer 自動改連新的 Leader。每個 Partition 的 Leader 會平均分散在各台 Broker 上，所以每台 Broker 都在分擔讀寫。",[11,6271,6272,6275,6276,6278,6279,6282],{},[15,6273,6274],{},"unclean.leader.election.enable"," 預設是 ",[15,6277,5575],{},"，意思是",[23,6280,6281],{},"寧可這個 Partition 暫時不能用，也不讓一個落後的副本當 Leader","，因為落後的副本當上 Leader，就代表沒同步到的那些訊息永遠遺失了。",[28,6284,6286],{"id":6285},"producer寫入怎樣才算成功","Producer：寫入怎樣才算成功",[100,6288,6289],{"id":6289},"acks",[11,6291,6292,6293,6295],{},"Producer 的 ",[15,6294,6289],{}," 決定「要多少個副本收到，才算寫入成功」：",[132,6297,6298,6309],{},[135,6299,6300],{},[138,6301,6302,6304,6307],{},[141,6303,6289],{},[141,6305,6306],{},"意思",[141,6308,971],{},[152,6310,6311,6323,6335],{},[138,6312,6313,6317,6320],{},[157,6314,6315],{},[15,6316,4975],{},[157,6318,6319],{},"送出去就算成功，不等回應",[157,6321,6322],{},"Broker 沒收到也不知道",[138,6324,6325,6329,6332],{},[157,6326,6327],{},[15,6328,892],{},[157,6330,6331],{},"Leader 寫入就算成功",[157,6333,6334],{},"Leader 寫完、還沒同步給 Follower 就掛了，訊息遺失",[138,6336,6337,6346,6349],{},[157,6338,6339,6342,6343,4898],{},[15,6340,6341],{},"all","（",[15,6344,6345],{},"-1",[157,6347,6348],{},"ISR 中所有副本都寫入才算成功",[157,6350,6351],{},"最安全，延遲稍高",[11,6353,6354,6357,6358,6363,6364,6367],{},[15,6355,6356],{},"acks=all"," 要搭配 Topic 的 ",[23,6359,6360],{},[15,6361,6362],{},"min.insync.replicas"," 才有意義。如果 ISR 只剩 Leader 自己，「所有 ISR 都寫入」其實等於 ",[15,6365,6366],{},"acks=1","。常見的組合是：",[36,6369,6372],{"className":6370,"code":6371,"language":41,"meta":44},[39],"replication.factor  = 3\nmin.insync.replicas = 2\nacks                = all\n",[15,6373,6371],{"__ignoreMap":44},[11,6375,6376,6377,6380],{},"意思是每則訊息至少要寫進兩個副本才算成功，",[23,6378,6379],{},"可以容忍一台 Broker 故障","；如果同時掛了兩台，ISR 只剩一個，Producer 會收到錯誤而不是默默把資料寫進一個隨時可能消失的副本。",[100,6382,6384],{"id":6383},"冪等-producer","冪等 Producer",[11,6386,6387,6388,6392,6393,114],{},"網路抖動時，Producer 可能送出了訊息、但沒收到 Broker 的確認，於是重送，造成同一則訊息寫入兩次。開啟 ",[23,6389,6390],{},[15,6391,4897],{}," 後，Broker 會用 Producer ID 加序號辨識重送的訊息並丟掉重複的，同時保證重試不會打亂同一個 Partition 內的順序。Java Client 從 Kafka 3.0 開始預設開啟；但 .NET 的 Confluent.Kafka 底層是 librdkafka，預設仍是關閉，要自己設定 ",[15,6394,6395],{},"EnableIdempotence = true",[100,6397,6398],{"id":6398},"批次與壓縮",[11,6400,6401],{},"Producer 不會每則訊息都送一次請求，而是累積成批次再送：",[66,6403,6404,6410,6416],{},[69,6405,6406,6409],{},[15,6407,6408],{},"linger.ms","：最多等多久湊一批，調大一點（例如 5～20ms）可以明顯提升吞吐量",[69,6411,6412,6415],{},[15,6413,6414],{},"batch.size","：一批最大多少 Byte",[69,6417,6418,55,6421,2276,6424,6427],{},[15,6419,6420],{},"compression.type",[15,6422,6423],{},"lz4",[15,6425,6426],{},"zstd"," 等壓縮，對日誌這類重複性高的文字效果很好",[28,6429,6431],{"id":6430},"保留與壓縮訊息什麼時候被清掉","保留與壓縮：訊息什麼時候被清掉",[11,6433,6434,6435,6438],{},"Topic 的 ",[15,6436,6437],{},"cleanup.policy"," 有兩種：",[66,6440,6441,6462],{},[69,6442,6443,6449,6450,6453,6454,6457,6458,6461],{},[23,6444,6445,6448],{},[15,6446,6447],{},"delete","（預設）","：依 ",[15,6451,6452],{},"retention.ms","（例如 7 天）或 ",[15,6455,6456],{},"retention.bytes"," 清除舊的資料，",[23,6459,6460],{},"不管有沒有被讀過","。所以 Consumer 停機超過保留期限，沒讀到的資料就真的沒了",[69,6463,6464,6469,6470,6473,6474,6477],{},[23,6465,6466],{},[15,6467,6468],{},"compact","：同一個 Key ",[23,6471,6472],{},"只保留最新的一筆","。適合存放「狀態」，例如每個帳號最新的設定，新的服務上線時從頭讀一遍，就能拿到每個 Key 的最新值。發一則 Value 為 ",[15,6475,6476],{},"null"," 的訊息（Tombstone）可以刪除這個 Key",[28,6479,6048],{"id":6480},"consumer-group",[11,6482,6483],{},[1952,6484],{"alt":6485,"src":6486},"Kafka Consumer Group 示意圖：Topic app-logs 有三個 Partition；Consumer Group A（log-indexer，寫入搜尋引擎）有四個 Consumer，Consumer 1、2、3 各分到 P0、P1、P2，第四個 Consumer 因為 Partition 不夠而閒置，組內分工時一個 Partition 只給組內一個 Consumer；Consumer Group B（log-archiver，封存到物件儲存）只有一個 Consumer，同時讀取 P0、P1、P2，組與組之間互不影響，每個 Group 都讀到完整的資料並各自記錄 offset","\u002Fimages\u002Farticles\u002Fkafka-consumer-groups.svg",[11,6488,6489,6491,6492,6494],{},[23,6490,6048],{}," 是一群共同消費同一個 Topic 的 Consumer，用 ",[15,6493,4197],{}," 識別。它同時解決了兩件事：",[11,6496,6497],{},[23,6498,6499],{},"1. 組內分工：一個 Partition 只會分給組內的一個 Consumer",[11,6501,6502],{},"Group A 有 3 個 Consumer、Topic 有 3 個 Partition，剛好一人一個，三個 Consumer 平行處理。",[11,6504,6505,6506,6509],{},"這也代表：",[23,6507,6508],{},"Partition 的數量，就是一個 Consumer Group 的平行度上限","。Group A 開到第 4 個 Consumer 時，已經沒有 Partition 可以分給它，它只會閒置，什麼都不做，只有在其他 Consumer 離開、觸發重新分配時才可能分到工作。想要再提高消費速度，必須先增加 Partition。",[11,6511,6512],{},"反過來，Consumer 比 Partition 少時，一個 Consumer 會同時負責多個 Partition，例如 Group B 只有 1 個 Consumer，就自己讀全部 3 個 Partition。",[11,6514,6515],{},[23,6516,6517],{},"2. 組與組之間互不影響：每個 Group 都讀到完整的資料",[11,6519,6520,6521,6524],{},"Group A 把日誌寫進搜尋引擎、Group B 把日誌封存到物件儲存，兩者都讀同一個 Topic，",[23,6522,6523],{},"各自讀到全部的訊息","，各自記錄自己讀到的 Offset。Group B 處理得比較慢，完全不會影響 Group A。",[11,6526,6527,6528],{},"一句話記：",[23,6529,6530],{},"同一個 Group 內是分工，不同 Group 之間是各自完整讀一份。",[100,6532,6534],{"id":6533},"offset-由-consumer-group-自己記錄","Offset 由 Consumer Group 自己記錄",[11,6536,6537,6538,6541],{},"每個 Consumer Group 會把自己在每個 Partition 讀到的位置，提交（Commit）回 Kafka 內部的 ",[15,6539,6540],{},"__consumer_offsets"," Topic。Consumer 重啟或換人接手時，就從上次提交的位置繼續讀。",[11,6543,6544,6545,6548,6549,6552],{},"一個",[23,6546,6547],{},"全新的"," Group（還沒有任何提交紀錄）要從哪裡開始讀，由 ",[15,6550,6551],{},"auto.offset.reset"," 決定：",[66,6554,6555,6561],{},[69,6556,6557,6560],{},[15,6558,6559],{},"latest","（預設）：從現在開始，之前的訊息都不讀",[69,6562,6563,6566],{},[15,6564,6565],{},"earliest","：從保留範圍內最舊的訊息開始讀",[11,6568,6569,6570,6572],{},"新上線的服務如果需要補齊歷史資料，要記得設成 ",[15,6571,6565],{},"，不然會以為「怎麼少了之前的資料」。",[11,6574,6575],{},"提交的時機決定了投遞語意：",[66,6577,6578,6588],{},[69,6579,6580,6583,6584,6587],{},[23,6581,6582],{},"先提交、再處理","：處理到一半當掉，重啟後會從下一筆開始，這一筆",[23,6585,6586],{},"遺失","（At-most-once）",[69,6589,6590,6593,6594,6597],{},[23,6591,6592],{},"先處理、再提交","：處理完、提交前當掉，重啟後會",[23,6595,6596],{},"重複處理","這一筆（At-least-once）",[11,6599,6600,6603,6604,6607],{},[15,6601,6602],{},"enable.auto.commit=true","（預設）會在背景每 5 秒自動提交「已經拉回來的位置」，不管你處理完了沒有，所以兩種情況都可能發生。需要可靠處理時，建議關掉自動提交，處理完再手動提交。大多數系統選擇 At-least-once，並讓處理邏輯",[23,6605,6606],{},"冪等","，重複處理也不會出錯。",[11,6609,6610,6611,55],{},"因為 Offset 可以自由調整，Kafka 還能做到傳統佇列做不到的事：發現某段時間的資料處理錯了，修好程式後",[23,6612,6613],{},"把 Offset 倒回去重新消費",[36,6615,6617],{"className":1006,"code":6616,"language":1008,"meta":44,"style":44},"# 把 log-indexer 這個 Group 倒回到昨天早上 9 點（Group 要先停止所有 Consumer）\nkafka-consumer-groups.sh --bootstrap-server kafka:9092 \\\n  --group log-indexer --topic app-logs \\\n  --reset-offsets --to-datetime 2025-05-12T09:00:00.000 --execute\n",[15,6618,6619,6624,6638,6654],{"__ignoreMap":44},[376,6620,6621],{"class":378,"line":379},[376,6622,6623],{"class":382},"# 把 log-indexer 這個 Group 倒回到昨天早上 9 點（Group 要先停止所有 Consumer）\n",[376,6625,6626,6629,6632,6635],{"class":378,"line":386},[376,6627,6628],{"class":1020},"kafka-consumer-groups.sh",[376,6630,6631],{"class":560}," --bootstrap-server",[376,6633,6634],{"class":414}," kafka:9092",[376,6636,6637],{"class":560}," \\\n",[376,6639,6640,6643,6646,6649,6652],{"class":378,"line":397},[376,6641,6642],{"class":560},"  --group",[376,6644,6645],{"class":414}," log-indexer",[376,6647,6648],{"class":560}," --topic",[376,6650,6651],{"class":414}," app-logs",[376,6653,6637],{"class":560},[376,6655,6656,6659,6662,6665],{"class":378,"line":405},[376,6657,6658],{"class":560},"  --reset-offsets",[376,6660,6661],{"class":560}," --to-datetime",[376,6663,6664],{"class":414}," 2025-05-12T09:00:00.000",[376,6666,6667],{"class":560}," --execute\n",[100,6669,6671],{"id":6670},"rebalance","Rebalance",[11,6673,6674,6675,114],{},"Consumer Group 的成員變動時——新增 Consumer、某個 Consumer 當掉或太久沒有回應、Topic 的 Partition 數量改變——Kafka 會重新分配 Partition，這個過程叫 ",[23,6676,6671],{},[11,6678,6679,6680,6683,6684,6686,6687,6690],{},"Rebalance 期間，相關的 Partition 會暫停消費。傳統的做法（Eager）是",[23,6681,6682],{},"所有 Consumer 先放掉全部 Partition，再重新分配","，整個 Group 停下來一次；改用 Cooperative Sticky 的漸進式 Rebalance（librdkafka／.NET 設定 ",[15,6685,5595],{},"，Java 是 ",[15,6688,6689],{},"CooperativeStickyAssignor","），只會搬動需要移動的 Partition，其他的照常消費。",[11,6692,6693],{},"判斷 Consumer 是否還活著有兩個逾時：",[66,6695,6696,6701],{},[69,6697,6698,6700],{},[15,6699,5609],{},"（預設 45 秒）：多久沒收到心跳就判定 Consumer 掛了",[69,6702,6703,6705,6706,6709],{},[15,6704,4505],{},"（預設 5 分鐘）：兩次 ",[15,6707,6708],{},"poll()"," 之間最多隔多久",[11,6711,6712,6713,6715,6716,6719],{},"常見的問題是處理單批訊息的時間太長，超過 ",[15,6714,4505],{},"，Kafka 以為這個 Consumer 卡住而把它踢出 Group、觸發 Rebalance，接著另一個 Consumer 接手、重複處理，一直循環。處理時間長的工作，要調小每次拉取的筆數（",[15,6717,6718],{},"max.poll.records","）或調大這個逾時設定。",[28,6721,6723],{"id":6722},"partition-要開幾個","Partition 要開幾個？",[11,6725,6726],{},"Partition 數量是設計 Topic 時最重要的決定之一：",[66,6728,6729,6735,6748],{},[69,6730,6731,6734],{},[23,6732,6733],{},"由消費端反推","：預估尖峰時每秒要處理多少訊息、單一 Consumer 每秒能處理多少，相除就是需要的平行度，Partition 數量至少要這麼多，再預留成長空間",[69,6736,6737,6740,6741,6747],{},[23,6738,6739],{},"可以增加、不能減少","：Partition 可以事後增加，但",[23,6742,6743,6744,6746],{},"增加後 ",[15,6745,5178],{}," 的結果會改變","，同一個 Key 之後會進到不同的 Partition，增加的那一刻前後的順序就無法保證。對順序敏感的 Topic，一開始就要開足",[69,6749,6750,6753],{},[23,6751,6752],{},"不是越多越好","：每個 Partition 都有檔案控制代碼、複本同步、Rebalance 的成本，數量過多會拉長故障恢復與 Rebalance 的時間",[28,6755,6757],{"id":6756},"使用情境一集中式日誌收集","使用情境一：集中式日誌收集",[11,6759,6760],{},"跨機房的應用程式日誌全部送進 Kafka，再由不同的系統各自消費：",[66,6762,6763,6768,6777,6786,6794],{},[69,6764,5320,6765,6767],{},[15,6766,6000],{}," 不設 Key，讓訊息平均分散在所有 Partition，追求最大吞吐量——日誌之間不需要嚴格順序",[69,6769,6770,6771,6773,6774,6776],{},"Producer 開啟 ",[15,6772,6423],{}," 壓縮與較大的 ",[15,6775,6408],{},"，日誌的文字重複性高，壓縮後網路與磁碟用量都能省下一大截",[69,6778,6779,6785],{},[23,6780,6781,6782],{},"Group ",[15,6783,6784],{},"log-indexer","：寫入搜尋引擎供查詢，Consumer 數量隨 Partition 數量水平擴展",[69,6787,6788,6793],{},[23,6789,6781,6790],{},[15,6791,6792],{},"log-archiver","：封存到物件儲存做長期保存",[69,6795,6796,6797,6799,6800],{},"搜尋引擎維護或故障時，",[15,6798,6784],{}," 停下來，日誌仍然安全地留在 Kafka 中；恢復後從上次的 Offset 繼續追上，不會遺失——前提是停機時間不能超過 ",[15,6801,6452],{},[11,6803,6804],{},"日誌量成長時，增加 Partition 與 Consumer 就能擴充處理量；Kafka 本身也成為應用程式與搜尋引擎之間的緩衝，尖峰流量不會直接打垮後端。",[28,6806,6808],{"id":6807},"使用情境二需要順序的交易事件","使用情境二：需要順序的交易事件",[11,6810,6811,6812,6815],{},"委託、成交、帳務異動這類事件，",[23,6813,6814],{},"同一個帳號的事件順序不能錯","：先成交再取消，和先取消再成交，結果完全不同。",[66,6817,6818,6825,6828,6841,6844],{},[69,6819,6820,6821,6824],{},"以",[23,6822,6823],{},"帳號 ID 作為 Key","，同一個帳號的所有事件都進同一個 Partition，依序被同一個 Consumer 處理",[69,6826,6827],{},"不同帳號之間不需要順序，分散在不同 Partition 平行處理",[69,6829,6830,6831,6833,6834,2276,6837,6840],{},"Producer 使用 ",[15,6832,6356],{}," 加冪等，Topic 設定 ",[15,6835,6836],{},"replication.factor=3",[15,6838,6839],{},"min.insync.replicas=2","，一台 Broker 故障也不掉資料",[69,6842,6843],{},"Consumer 關閉自動提交，採用「先處理、再提交 Offset」，並以事件 ID 做冪等檢查，重複投遞也不會重複入帳",[69,6845,6846],{},"一開始就依預估量開足 Partition，避免日後擴充造成同一帳號的事件跨 Partition",[28,6848,1890],{"id":1890},[11,6850,6851,6852,3860,6854,6856],{},"Kafka 是一份可重複讀取的分散式日誌：Topic 切成多個 Partition 分散在各台 Broker，每個 Partition 再複製成 Leader 與 Follower，搭配 ",[15,6853,6356],{},[15,6855,6362],{}," 撐過 Broker 故障；順序只在 Partition 內成立，所以用 Key 決定「哪些訊息要有序」；Consumer Group 內一個 Partition 只給一個 Consumer，因此 Partition 數量就是平行度上限，不同 Group 各自讀到完整的資料並各自記錄 Offset，讓同一份資料能被多個系統獨立、可重播地消費。",[1132,6858,6859],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":44,"searchDepth":386,"depth":386,"links":6861},[6862,6863,6864,6868,6869,6874,6875,6879,6880,6881,6882],{"id":5908,"depth":386,"text":5909},{"id":5940,"depth":386,"text":5940},{"id":6068,"depth":386,"text":6069,"children":6865},[6866,6867],{"id":6104,"depth":397,"text":6105},{"id":6130,"depth":397,"text":6131},{"id":6218,"depth":386,"text":6219},{"id":6285,"depth":386,"text":6286,"children":6870},[6871,6872,6873],{"id":6289,"depth":397,"text":6289},{"id":6383,"depth":397,"text":6384},{"id":6398,"depth":397,"text":6398},{"id":6430,"depth":386,"text":6431},{"id":6480,"depth":386,"text":6048,"children":6876},[6877,6878],{"id":6533,"depth":397,"text":6534},{"id":6670,"depth":397,"text":6671},{"id":6722,"depth":386,"text":6723},{"id":6756,"depth":386,"text":6757},{"id":6807,"depth":386,"text":6808},{"id":1890,"depth":386,"text":1890},"2025-05-13",{},{"title":5903,"description":44},"articles\u002Fkafka-topic-partition-consumer-group",[4029,6888,5899,3039],"事件串流","0K0d4ZhyVYdOR_ZG8z3r3zS0vwdA_5ROeYwDyY2lsFk",{"id":6891,"title":6892,"body":6893,"date":7815,"description":44,"excerpt":1156,"extension":1157,"meta":7816,"navigation":1035,"path":7817,"seo":7818,"stem":7819,"tags":7820,"__hash__":7823},"articles\u002Farticles\u002Fargocd-gitops.md","GitOps 與 Argo CD：為什麼部署要從「推」改成「拉」",{"type":8,"value":6894,"toc":7805},[6895,6899,6902,6930,6937,6967,6971,6981,7007,7013,7020,7042,7045,7049,7058,7258,7261,7289,7293,7299,7366,7380,7397,7406,7410,7417,7477,7484,7488,7491,7496,7502,7505,7510,7517,7748,7751,7756,7759,7763,7766,7787,7789,7802],[28,6896,6898],{"id":6897},"push-模式的問題","Push 模式的問題",[11,6900,6901],{},"最常見的 Kubernetes 部署方式，是在 CI Pipeline 的最後一步直接部署：",[36,6903,6905],{"className":1006,"code":6904,"language":1008,"meta":44,"style":44},"helm upgrade --install order-api .\u002Fcharts\u002Forder-api -f envs\u002Fprod\u002Fvalues.yaml\n",[15,6906,6907],{"__ignoreMap":44},[376,6908,6909,6912,6915,6918,6921,6924,6927],{"class":378,"line":379},[376,6910,6911],{"class":1020},"helm",[376,6913,6914],{"class":414}," upgrade",[376,6916,6917],{"class":560}," --install",[376,6919,6920],{"class":414}," order-api",[376,6922,6923],{"class":414}," .\u002Fcharts\u002Forder-api",[376,6925,6926],{"class":560}," -f",[376,6928,6929],{"class":414}," envs\u002Fprod\u002Fvalues.yaml\n",[11,6931,6932,6933,6936],{},"這叫 ",[23,6934,6935],{},"Push 模式","：由 Pipeline 把變更「推」進叢集。它可以運作，但隨著叢集與服務變多，問題會一一浮現：",[66,6938,6939,6945,6955,6961],{},[69,6940,6941,6944],{},[23,6942,6943],{},"CI 系統要握有叢集的管理權限","：每一個 Pipeline 都要有能改正式環境的憑證，CI 系統一旦被入侵，所有叢集都暴露",[69,6946,6947,6950,6951,6954],{},[23,6948,6949],{},"叢集的實際狀態沒人知道","：有人半夜手動 ",[15,6952,6953],{},"kubectl edit"," 調整了設定解決問題，事後沒有改回設定檔，下次部署時又被覆蓋回去，問題重現",[69,6956,6957,6960],{},[23,6958,6959],{},"「現在線上是什麼版本」要去叢集裡查","：沒有一個地方可以一眼看出每個環境部署了什麼",[69,6962,6963,6966],{},[23,6964,6965],{},"回滾要重跑 Pipeline","：而且要記得當時是用哪一個 commit、哪一份 values 部署的",[28,6968,6970],{"id":6969},"gitopsgit-是唯一的真相","GitOps：Git 是唯一的真相",[11,6972,6973,6976,6977,6980],{},[23,6974,6975],{},"GitOps"," 的核心想法是：",[23,6978,6979],{},"叢集應該是什麼樣子，完全由 Git 上的檔案描述","。OpenGitOps 定義了四個原則：",[211,6982,6983,6989,6995,7001],{},[69,6984,6985,6988],{},[23,6986,6987],{},"宣告式（Declarative）","：系統的期望狀態以宣告式的設定描述（Kubernetes Manifest、Helm values）",[69,6990,6991,6994],{},[23,6992,6993],{},"版本化且不可變（Versioned and Immutable）","：期望狀態存在 Git 中，每一次變更都有紀錄，可以追溯、可以回到任何一個版本",[69,6996,6997,7000],{},[23,6998,6999],{},"自動拉取（Pulled Automatically）","：由叢集內的代理程式主動從 Git 拉取期望狀態，而不是由外部推入",[69,7002,7003,7006],{},[23,7004,7005],{},"持續調和（Continuously Reconciled）","：代理程式持續比對「期望狀態」與「實際狀態」，發現不一致就修正",[11,7008,7009],{},[1952,7010],{"alt":7011,"src":7012},"GitOps 與 Argo CD 流程示意圖：CI Pipeline 建置測試並把映像推到 Image Registry，再更新 Git 上 values.yaml 的 image.tag；工程師調整設定也是發 PR 到 Git；Argo CD 從 Git 拉取期望狀態，比對期望與實際，不一致就同步到 dev 與 prod 叢集；如果有人手動 kubectl scale 把 replicas 改成 8，Argo CD 會標示 OutOfSync，開啟 selfHeal 時自動改回 Git 上的 replicas 4","\u002Fimages\u002Farticles\u002Fargocd-gitops.svg",[11,7014,7015,7016,7019],{},"在 GitOps 的流程裡，",[23,7017,7018],{},"CI 和 CD 被拆開","了：",[66,7021,7022,7032],{},[69,7023,7024,7027,7028,7031],{},[23,7025,7026],{},"CI"," 只負責建置、測試、推送映像，最後",[23,7029,7030],{},"更新 Git 上的映像版本號","（發一個 commit 或 PR），不碰叢集",[69,7033,7034,7037,7038,7041],{},[23,7035,7036],{},"CD"," 由叢集內的 ",[23,7039,7040],{},"Argo CD"," 負責：它看到 Git 變了，就把叢集同步成 Git 描述的狀態",[11,7043,7044],{},"部署變成「改 Git」，回滾變成「revert 一個 commit」，稽核變成「看 Git log」。",[28,7046,7048],{"id":7047},"argo-cd-的核心概念application","Argo CD 的核心概念：Application",[11,7050,7051,7052,7057],{},"Argo CD 用一個 CRD ",[23,7053,7054],{},[15,7055,7056],{},"Application"," 描述「把哪個 Git 路徑的內容，部署到哪個叢集的哪個 Namespace」：",[36,7059,7061],{"className":370,"code":7060,"language":372,"meta":44,"style":44},"apiVersion: argoproj.io\u002Fv1alpha1\nkind: Application\nmetadata:\n  name: order-api-prod\n  namespace: argocd\nspec:\n  project: trading\n  source:\n    repoURL: https:\u002F\u002Fgit.example.com\u002Fplatform\u002Fdeploy.git\n    targetRevision: main\n    path: charts\u002Forder-api\n    helm:\n      valueFiles:\n        - ..\u002F..\u002Fenvs\u002Fprod\u002Forder-api\u002Fvalues.yaml\n  destination:\n    server: https:\u002F\u002Fprod-cluster.example.com\n    namespace: order\n  syncPolicy:\n    automated:\n      prune: true\n      selfHeal: true\n    syncOptions:\n      - CreateNamespace=true\n",[15,7062,7063,7072,7081,7087,7096,7106,7112,7122,7129,7139,7149,7159,7166,7173,7180,7187,7197,7207,7214,7221,7231,7241,7249],{"__ignoreMap":44},[376,7064,7065,7067,7069],{"class":378,"line":379},[376,7066,472],{"class":389},[376,7068,411],{"class":393},[376,7070,7071],{"class":414},"argoproj.io\u002Fv1alpha1\n",[376,7073,7074,7076,7078],{"class":378,"line":386},[376,7075,482],{"class":389},[376,7077,411],{"class":393},[376,7079,7080],{"class":414},"Application\n",[376,7082,7083,7085],{"class":378,"line":397},[376,7084,492],{"class":389},[376,7086,394],{"class":393},[376,7088,7089,7091,7093],{"class":378,"line":405},[376,7090,499],{"class":389},[376,7092,411],{"class":393},[376,7094,7095],{"class":414},"order-api-prod\n",[376,7097,7098,7101,7103],{"class":378,"line":418},[376,7099,7100],{"class":389},"  namespace",[376,7102,411],{"class":393},[376,7104,7105],{"class":414},"argocd\n",[376,7107,7108,7110],{"class":378,"line":429},[376,7109,509],{"class":389},[376,7111,394],{"class":393},[376,7113,7114,7117,7119],{"class":378,"line":437},[376,7115,7116],{"class":389},"  project",[376,7118,411],{"class":393},[376,7120,7121],{"class":414},"trading\n",[376,7123,7124,7127],{"class":378,"line":447},[376,7125,7126],{"class":389},"  source",[376,7128,394],{"class":393},[376,7130,7131,7134,7136],{"class":378,"line":531},[376,7132,7133],{"class":389},"    repoURL",[376,7135,411],{"class":393},[376,7137,7138],{"class":414},"https:\u002F\u002Fgit.example.com\u002Fplatform\u002Fdeploy.git\n",[376,7140,7141,7144,7146],{"class":378,"line":542},[376,7142,7143],{"class":389},"    targetRevision",[376,7145,411],{"class":393},[376,7147,7148],{"class":414},"main\n",[376,7150,7151,7154,7156],{"class":378,"line":552},[376,7152,7153],{"class":389},"    path",[376,7155,411],{"class":393},[376,7157,7158],{"class":414},"charts\u002Forder-api\n",[376,7160,7161,7164],{"class":378,"line":564},[376,7162,7163],{"class":389},"    helm",[376,7165,394],{"class":393},[376,7167,7168,7171],{"class":378,"line":575},[376,7169,7170],{"class":389},"      valueFiles",[376,7172,394],{"class":393},[376,7174,7175,7177],{"class":378,"line":583},[376,7176,875],{"class":393},[376,7178,7179],{"class":414},"..\u002F..\u002Fenvs\u002Fprod\u002Forder-api\u002Fvalues.yaml\n",[376,7181,7182,7185],{"class":378,"line":597},[376,7183,7184],{"class":389},"  destination",[376,7186,394],{"class":393},[376,7188,7189,7192,7194],{"class":378,"line":605},[376,7190,7191],{"class":389},"    server",[376,7193,411],{"class":393},[376,7195,7196],{"class":414},"https:\u002F\u002Fprod-cluster.example.com\n",[376,7198,7199,7202,7204],{"class":378,"line":616},[376,7200,7201],{"class":389},"    namespace",[376,7203,411],{"class":393},[376,7205,7206],{"class":414},"order\n",[376,7208,7209,7212],{"class":378,"line":624},[376,7210,7211],{"class":389},"  syncPolicy",[376,7213,394],{"class":393},[376,7215,7216,7219],{"class":378,"line":635},[376,7217,7218],{"class":389},"    automated",[376,7220,394],{"class":393},[376,7222,7223,7226,7228],{"class":378,"line":5815},[376,7224,7225],{"class":389},"      prune",[376,7227,411],{"class":393},[376,7229,7230],{"class":560},"true\n",[376,7232,7234,7237,7239],{"class":378,"line":7233},21,[376,7235,7236],{"class":389},"      selfHeal",[376,7238,411],{"class":393},[376,7240,7230],{"class":560},[376,7242,7244,7247],{"class":378,"line":7243},22,[376,7245,7246],{"class":389},"    syncOptions",[376,7248,394],{"class":393},[376,7250,7252,7255],{"class":378,"line":7251},23,[376,7253,7254],{"class":393},"      - ",[376,7256,7257],{"class":414},"CreateNamespace=true\n",[11,7259,7260],{},"每個 Application 都有兩個狀態：",[66,7262,7263,7275],{},[69,7264,7265,7268,7269,2390,7272],{},[23,7266,7267],{},"Sync Status","：叢集的實際狀態和 Git 是否一致——",[15,7270,7271],{},"Synced",[15,7273,7274],{},"OutOfSync",[69,7276,7277,7280,7281,2276,7283,2276,7286],{},[23,7278,7279],{},"Health Status","：部署出去的資源是否健康——Deployment 的 Pod 都 Ready 了嗎？",[15,7282,3368],{},[15,7284,7285],{},"Progressing",[15,7287,7288],{},"Degraded",[28,7290,7292],{"id":7291},"同步策略automatedpruneselfheal","同步策略：automated、prune、selfHeal",[11,7294,7295,7298],{},[15,7296,7297],{},"syncPolicy"," 決定 Argo CD 發現不一致時要做什麼：",[132,7300,7301,7314],{},[135,7302,7303],{},[138,7304,7305,7308,7311],{},[141,7306,7307],{},"設定",[141,7309,7310],{},"作用",[141,7312,7313],{},"沒開的話",[152,7315,7316,7332,7349],{},[138,7317,7318,7323,7329],{},[157,7319,7320],{},[15,7321,7322],{},"automated",[157,7324,7325,7326],{},"Git 有變更時",[23,7327,7328],{},"自動同步",[157,7330,7331],{},"只標示 OutOfSync，要人按下 Sync",[138,7333,7334,7339,7346],{},[157,7335,7336],{},[15,7337,7338],{},"prune: true",[157,7340,7341,7342,7345],{},"Git 上",[23,7343,7344],{},"刪掉","的資源，叢集裡也刪掉",[157,7347,7348],{},"資源會留在叢集裡變成孤兒",[138,7350,7351,7356,7363],{},[157,7352,7353],{},[15,7354,7355],{},"selfHeal: true",[157,7357,7358,7359,7362],{},"叢集被",[23,7360,7361],{},"手動修改","時，自動改回 Git 的狀態",[157,7364,7365],{},"只標示 OutOfSync，不主動修正",[11,7367,7368,7371,7372,7375,7376,7379],{},[15,7369,7370],{},"selfHeal"," 是 GitOps 最有感的一點：有人手動 ",[15,7373,7374],{},"kubectl scale --replicas=8","，Argo CD 幾秒內就會把它改回 Git 上的 ",[15,7377,7378],{},"replicas: 4","。這逼著所有變更都必須走 Git——想改副本數，就去改 values 檔發 PR。",[7381,7382,7383],"blockquote",{},[11,7384,7385,7386,7389,7390,7393,7394,114],{},"例外：和 HPA 一起使用時，副本數是由 HPA 動態決定的，Argo CD 不應該把它改回去。可以在 Application 中用 ",[15,7387,7388],{},"ignoreDifferences"," 忽略 Deployment 的 ",[15,7391,7392],{},"\u002Fspec\u002Freplicas","，或是乾脆不在 Manifest 中設定 ",[15,7395,7396],{},"replicas",[11,7398,7399,7400,7402,7403,7405],{},"正式環境要不要開 ",[15,7401,7322],{},"，各團隊的選擇不同：有些團隊在正式環境只開 ",[15,7404,7370],{}," 防止漂移，同步仍然由人在確認後按下，把「合併 PR」和「真正上線」拆成兩個決策點。",[28,7407,7409],{"id":7408},"sync-wave控制資源的部署順序","Sync Wave：控制資源的部署順序",[11,7411,7412,7413,7416],{},"一個應用程式可能包含資料庫 Migration Job、ConfigMap、Deployment，它們之間有先後順序：Migration 要先跑完，新版的程式才能啟動。Argo CD 用 ",[23,7414,7415],{},"Sync Wave"," 控制順序，數字小的先部署，等它健康了才進下一波：",[36,7418,7420],{"className":370,"code":7419,"language":372,"meta":44,"style":44},"metadata:\n  annotations:\n    argocd.argoproj.io\u002Fsync-wave: \"-1\"   # Migration Job：先跑\n---\nmetadata:\n  annotations:\n    argocd.argoproj.io\u002Fsync-wave: \"0\"    # Deployment：後跑（預設 0）\n",[15,7421,7422,7428,7435,7448,7453,7459,7465],{"__ignoreMap":44},[376,7423,7424,7426],{"class":378,"line":379},[376,7425,492],{"class":389},[376,7427,394],{"class":393},[376,7429,7430,7433],{"class":378,"line":386},[376,7431,7432],{"class":389},"  annotations",[376,7434,394],{"class":393},[376,7436,7437,7440,7442,7445],{"class":378,"line":397},[376,7438,7439],{"class":389},"    argocd.argoproj.io\u002Fsync-wave",[376,7441,411],{"class":393},[376,7443,7444],{"class":414},"\"-1\"",[376,7446,7447],{"class":382},"   # Migration Job：先跑\n",[376,7449,7450],{"class":378,"line":405},[376,7451,7452],{"class":1020},"---\n",[376,7454,7455,7457],{"class":378,"line":418},[376,7456,492],{"class":389},[376,7458,394],{"class":393},[376,7460,7461,7463],{"class":378,"line":429},[376,7462,7432],{"class":389},[376,7464,394],{"class":393},[376,7466,7467,7469,7471,7474],{"class":378,"line":437},[376,7468,7439],{"class":389},[376,7470,411],{"class":393},[376,7472,7473],{"class":414},"\"0\"",[376,7475,7476],{"class":382},"    # Deployment：後跑（預設 0）\n",[11,7478,7479,7480,7483],{},"搭配 ",[15,7481,7482],{},"argocd.argoproj.io\u002Fhook: PreSync"," 這類 Hook，還可以定義只在同步前或同步後執行的 Job，例如部署前的資料庫備份、部署後的冒煙測試。",[28,7485,7487],{"id":7486},"多叢集多環境怎麼規劃","多叢集、多環境怎麼規劃",[11,7489,7490],{},"當環境與服務一多，Application 的數量會快速增加。常見的規劃方式：",[11,7492,7493],{},[23,7494,7495],{},"Helm Chart 共用，環境差異只放在 values",[36,7497,7500],{"className":7498,"code":7499,"language":41,"meta":44},[39],"deploy\u002F\n├── charts\u002F\n│   └── order-api\u002F            ← 共用的 Chart\n└── envs\u002F\n    ├── dev\u002Forder-api\u002Fvalues.yaml\n    ├── uat\u002Forder-api\u002Fvalues.yaml\n    └── prod\u002Forder-api\u002Fvalues.yaml\n",[15,7501,7499],{"__ignoreMap":44},[11,7503,7504],{},"環境之間的差異（副本數、資源、網域、外部服務位址）集中在 values 檔，比對兩個環境的差異就是比對兩個檔案。",[11,7506,7507],{},[23,7508,7509],{},"用 ApplicationSet 產生 Application",[11,7511,7512,7513,7516],{},"不需要為「每個服務 × 每個環境」手寫一份 Application。",[15,7514,7515],{},"ApplicationSet"," 可以依 Git 目錄結構或叢集清單自動產生：",[36,7518,7520],{"className":370,"code":7519,"language":372,"meta":44,"style":44},"apiVersion: argoproj.io\u002Fv1alpha1\nkind: ApplicationSet\nmetadata:\n  name: order-api\nspec:\n  generators:\n    - list:\n        elements:\n          - env: dev\n            cluster: https:\u002F\u002Fdev-cluster.example.com\n          - env: prod\n            cluster: https:\u002F\u002Fprod-cluster.example.com\n  template:\n    metadata:\n      name: \"order-api-{{env}}\"\n    spec:\n      project: trading\n      source:\n        repoURL: https:\u002F\u002Fgit.example.com\u002Fplatform\u002Fdeploy.git\n        targetRevision: main\n        path: charts\u002Forder-api\n        helm:\n          valueFiles: [\"..\u002F..\u002Fenvs\u002F{{env}}\u002Forder-api\u002Fvalues.yaml\"]\n      destination:\n        server: \"{{cluster}}\"\n        namespace: order\n",[15,7521,7522,7530,7539,7545,7553,7559,7566,7575,7582,7595,7605,7616,7624,7631,7638,7648,7655,7664,7671,7680,7689,7698,7705,7719,7727,7738],{"__ignoreMap":44},[376,7523,7524,7526,7528],{"class":378,"line":379},[376,7525,472],{"class":389},[376,7527,411],{"class":393},[376,7529,7071],{"class":414},[376,7531,7532,7534,7536],{"class":378,"line":386},[376,7533,482],{"class":389},[376,7535,411],{"class":393},[376,7537,7538],{"class":414},"ApplicationSet\n",[376,7540,7541,7543],{"class":378,"line":397},[376,7542,492],{"class":389},[376,7544,394],{"class":393},[376,7546,7547,7549,7551],{"class":378,"line":405},[376,7548,499],{"class":389},[376,7550,411],{"class":393},[376,7552,504],{"class":414},[376,7554,7555,7557],{"class":378,"line":418},[376,7556,509],{"class":389},[376,7558,394],{"class":393},[376,7560,7561,7564],{"class":378,"line":429},[376,7562,7563],{"class":389},"  generators",[376,7565,394],{"class":393},[376,7567,7568,7570,7573],{"class":378,"line":437},[376,7569,586],{"class":393},[376,7571,7572],{"class":389},"list",[376,7574,394],{"class":393},[376,7576,7577,7580],{"class":378,"line":447},[376,7578,7579],{"class":389},"        elements",[376,7581,394],{"class":393},[376,7583,7584,7587,7590,7592],{"class":378,"line":531},[376,7585,7586],{"class":393},"          - ",[376,7588,7589],{"class":389},"env",[376,7591,411],{"class":393},[376,7593,7594],{"class":414},"dev\n",[376,7596,7597,7600,7602],{"class":378,"line":542},[376,7598,7599],{"class":389},"            cluster",[376,7601,411],{"class":393},[376,7603,7604],{"class":414},"https:\u002F\u002Fdev-cluster.example.com\n",[376,7606,7607,7609,7611,7613],{"class":378,"line":552},[376,7608,7586],{"class":393},[376,7610,7589],{"class":389},[376,7612,411],{"class":393},[376,7614,7615],{"class":414},"prod\n",[376,7617,7618,7620,7622],{"class":378,"line":564},[376,7619,7599],{"class":389},[376,7621,411],{"class":393},[376,7623,7196],{"class":414},[376,7625,7626,7629],{"class":378,"line":575},[376,7627,7628],{"class":389},"  template",[376,7630,394],{"class":393},[376,7632,7633,7636],{"class":378,"line":583},[376,7634,7635],{"class":389},"    metadata",[376,7637,394],{"class":393},[376,7639,7640,7643,7645],{"class":378,"line":597},[376,7641,7642],{"class":389},"      name",[376,7644,411],{"class":393},[376,7646,7647],{"class":414},"\"order-api-{{env}}\"\n",[376,7649,7650,7653],{"class":378,"line":605},[376,7651,7652],{"class":389},"    spec",[376,7654,394],{"class":393},[376,7656,7657,7660,7662],{"class":378,"line":616},[376,7658,7659],{"class":389},"      project",[376,7661,411],{"class":393},[376,7663,7121],{"class":414},[376,7665,7666,7669],{"class":378,"line":624},[376,7667,7668],{"class":389},"      source",[376,7670,394],{"class":393},[376,7672,7673,7676,7678],{"class":378,"line":635},[376,7674,7675],{"class":389},"        repoURL",[376,7677,411],{"class":393},[376,7679,7138],{"class":414},[376,7681,7682,7685,7687],{"class":378,"line":5815},[376,7683,7684],{"class":389},"        targetRevision",[376,7686,411],{"class":393},[376,7688,7148],{"class":414},[376,7690,7691,7694,7696],{"class":378,"line":7233},[376,7692,7693],{"class":389},"        path",[376,7695,411],{"class":393},[376,7697,7158],{"class":414},[376,7699,7700,7703],{"class":378,"line":7243},[376,7701,7702],{"class":389},"        helm",[376,7704,394],{"class":393},[376,7706,7707,7710,7713,7716],{"class":378,"line":7251},[376,7708,7709],{"class":389},"          valueFiles",[376,7711,7712],{"class":393},": [",[376,7714,7715],{"class":414},"\"..\u002F..\u002Fenvs\u002F{{env}}\u002Forder-api\u002Fvalues.yaml\"",[376,7717,7718],{"class":393},"]\n",[376,7720,7722,7725],{"class":378,"line":7721},24,[376,7723,7724],{"class":389},"      destination",[376,7726,394],{"class":393},[376,7728,7730,7733,7735],{"class":378,"line":7729},25,[376,7731,7732],{"class":389},"        server",[376,7734,411],{"class":393},[376,7736,7737],{"class":414},"\"{{cluster}}\"\n",[376,7739,7741,7744,7746],{"class":378,"line":7740},26,[376,7742,7743],{"class":389},"        namespace",[376,7745,411],{"class":393},[376,7747,7206],{"class":414},[11,7749,7750],{},"新增一個環境，只要在清單中加一筆。",[11,7752,7753],{},[23,7754,7755],{},"應用程式原始碼和部署設定分開兩個 Repo",[11,7757,7758],{},"CI 更新映像版本時會 commit 到部署設定的 Repo。如果兩者放在同一個 Repo，CI 的 commit 又會觸發一次 CI，很容易形成迴圈；權限上，能改程式碼的人也不一定該能改正式環境的設定。",[28,7760,7762],{"id":7761},"使用情境證券系統導入-gitops","使用情境：證券系統導入 GitOps",[11,7764,7765],{},"在證券系統導入 Argo CD 時，主要想解決的是「正式環境的實際狀態不透明」和「部署權限散落在各個 Pipeline」：",[66,7767,7768,7771,7778,7784],{},[69,7769,7770],{},"各服務的 Helm Chart 與各環境的 values 集中在部署 Repo，正式環境的每一次變更都是一個經過審核的 PR",[69,7772,7773,7774,7777],{},"CI 只負責建置映像並更新 values 中的 ",[15,7775,7776],{},"image.tag","，不再持有叢集的管理權限",[69,7779,7780,7781,7783],{},"正式環境開啟 ",[15,7782,7370],{},"，手動調整會被自動修正，避免設定漂移",[69,7785,7786],{},"搭配 Centralized Pipeline Library，開發人員不需要自己撰寫部署腳本",[28,7788,1890],{"id":1890},[11,7790,7791,7792,7794,7795,7798,7799,7801],{},"GitOps 讓 Git 成為叢集期望狀態的唯一真相，CI 只負責產出映像並更新 Git，叢集內的 Argo CD 持續拉取、比對、同步；用 ",[15,7793,7322],{}," 自動部署、",[15,7796,7797],{},"prune"," 清掉被刪除的資源、",[15,7800,7370],{}," 修正手動漂移，再用 Sync Wave 控制部署順序、ApplicationSet 管理多環境，部署、回滾、稽核都回到 Git 上完成。",[1132,7803,7804],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":44,"searchDepth":386,"depth":386,"links":7806},[7807,7808,7809,7810,7811,7812,7813,7814],{"id":6897,"depth":386,"text":6898},{"id":6969,"depth":386,"text":6970},{"id":7047,"depth":386,"text":7048},{"id":7291,"depth":386,"text":7292},{"id":7408,"depth":386,"text":7409},{"id":7486,"depth":386,"text":7487},{"id":7761,"depth":386,"text":7762},{"id":1890,"depth":386,"text":1890},"2025-02-17",{},"\u002Farticles\u002Fargocd-gitops",{"title":6892,"description":44},"articles\u002Fargocd-gitops",[6975,7040,1163,7821,7822,1454],"Helm","CI\u002FCD","RzkH9X8s1ou6pcXdbeKUHly-NDBEkKDwxIUxPqu8PtI",{"id":7825,"title":7826,"body":7827,"date":8321,"description":8322,"excerpt":1156,"extension":1157,"meta":8323,"navigation":1035,"path":8324,"seo":8325,"stem":8326,"tags":8327,"__hash__":8333},"articles\u002Farticles\u002Fagile-mindset.md","敏捷需要的是思維，不是手法：從 Linda Rising 的〈The Power of an Agile Mindset〉談固定型與成長型思維",{"type":8,"value":7828,"toc":8305},[7829,7842,7847,7859,7865,7869,7872,7886,7893,7975,7979,7985,7991,7994,8038,8041,8044,8055,8058,8061,8065,8074,8084,8087,8093,8107,8110,8113,8116,8136,8139,8144,8148,8154,8157,8165,8168,8172,8175,8181,8191,8195,8198,8212,8219,8225,8229,8235,8255,8258,8261,8268,8300,8302],[11,7830,7831,7832,7836,7837,7841],{},"過去一年我花了很多時間在敏捷相關的東西上：整理 ",[119,7833,7835],{"href":7834},"\u002Farticles\u002Foverview-of-scrum","Scrum 的流程","、研究 Event Storming，也試著 ",[119,7838,7840],{"href":7839},"\u002Farticles\u002Fevent-storming-in-scrum","把 Event Storming 放進 Scrum","。越研究，我越常想起一個問題：",[7381,7843,7844],{},[11,7845,7846],{},"為什麼有些團隊 Daily Scrum、Review、Retrospective 一個都沒少，卻一點都不敏捷？而有些人從來沒聽過 Scrum，做事方式卻非常敏捷？",[11,7848,7849,7850,7855,7856,114],{},"後來看到 Linda Rising 的一場演講〈",[119,7851,7854],{"href":7852,"rel":7853},"https:\u002F\u002Fyoutu.be\u002FSMvVJwwMn5A?si=cgoK9mDZpPytwy2K",[3057],"The Power of an Agile Mindset","〉，覺得她把這件事講得很清楚。Linda Rising 是軟體界很資深的講者，長期在講模式（Patterns）和組織變革。這場演講幾乎沒有提到任何敏捷的手法，而是在講",[23,7857,7858],{},"人怎麼看待能力、失敗和努力",[11,7860,7861,7862],{},"這篇用影片中的例子整理她的觀點，最後再說說我自己的看法：",[23,7863,7864],{},"敏捷需要的是思維，不是手法。",[28,7866,7868],{"id":7867},"兩種思維能力是固定的還是可以成長的","兩種思維：能力是固定的，還是可以成長的？",[11,7870,7871],{},"演講的基礎是史丹佛大學心理學家 Carol Dweck 的研究（她的著作《Mindset》中文版譯為《心態致勝》）。Dweck 發現，人對「能力」有兩種不同的信念：",[66,7873,7874,7880],{},[69,7875,7876,7879],{},[23,7877,7878],{},"固定型思維（Fixed Mindset）","：聰明、才能是天生的，有就有、沒有就沒有",[69,7881,7882,7885],{},[23,7883,7884],{},"成長型思維（Growth Mindset）","：能力可以透過努力、練習、好的方法慢慢培養",[11,7887,7888,7889,7892],{},"Linda Rising 把後者直接稱為 ",[23,7890,7891],{},"敏捷思維（Agile Mindset）","。這兩種信念看起來只是想法不同，但會一路影響一個人的行為：",[132,7894,7895,7907],{},[135,7896,7897],{},[138,7898,7899,7901,7904],{},[141,7900],{},[141,7902,7903],{},"固定型思維",[141,7905,7906],{},"敏捷思維",[152,7908,7909,7920,7931,7942,7953,7964],{},[138,7910,7911,7914,7917],{},[157,7912,7913],{},"目標",[157,7915,7916],{},"看起來很厲害",[157,7918,7919],{},"學到東西",[138,7921,7922,7925,7928],{},[157,7923,7924],{},"面對挑戰",[157,7926,7927],{},"避開，怕暴露自己不夠好",[157,7929,7930],{},"迎上去，這是成長的機會",[138,7932,7933,7936,7939],{},[157,7934,7935],{},"面對失敗",[157,7937,7938],{},"失敗代表「我就是不行」",[157,7940,7941],{},"失敗是資訊，告訴我哪裡要改",[138,7943,7944,7947,7950],{},[157,7945,7946],{},"看待努力",[157,7948,7949],{},"需要努力代表沒天分",[157,7951,7952],{},"努力是變強的必經之路",[138,7954,7955,7958,7961],{},[157,7956,7957],{},"看待別人的成功",[157,7959,7960],{},"威脅",[157,7962,7963],{},"可以學習的對象",[138,7965,7966,7969,7972],{},[157,7967,7968],{},"遇到困難",[157,7970,7971],{},"無助、放棄",[157,7973,7974],{},"換個方法再試",[28,7976,7978],{"id":7977},"誇聰明還是誇努力一個實驗","誇「聰明」還是誇「努力」：一個實驗",[11,7980,7981,7982],{},"演講中最讓我印象深刻的，是 Dweck 對國小學生做的一連串實驗。Linda Rising 用它來說明：",[23,7983,7984],{},"一句不經意的稱讚，就能把人推向其中一種思維。",[11,7986,7987],{},[1952,7988],{"alt":7989,"src":7990},"Dweck 實驗的流程比較：學生先做一份簡單的題目，接著分成兩組，一組被稱讚「你好聰明」，另一組被稱讚「你很努力」。第二步讓學生自己選下一份題目，被誇聰明的大多選簡單的，被誇努力的大多選比較難、能學到東西的；第三步大家都做一份很難的題目，被誇聰明的很快就灰心，被誇努力的反而很投入；第四步可以看別人的考卷，被誇聰明的選擇看考得比自己差的，被誇努力的選擇看考得比自己好的；第五步再做一份簡單的題目，被誇聰明的成績下降，被誇努力的成績進步；最後回報分數時，被誇聰明的有不少人謊報分數","\u002Fimages\u002Farticles\u002Fagile-mindset-praise-experiment.svg",[11,7992,7993],{},"實驗的流程大致是這樣：",[211,7995,7996,8002,8011,8017,8023,8029],{},[69,7997,7998,8001],{},[23,7999,8000],{},"一份簡單的題目","：大家都做得不錯。做完後，一組被稱讚「你好聰明」，另一組被稱讚「你很努力」。就只差這一句話",[69,8003,8004,8007,8008],{},[23,8005,8006],{},"自己選下一份題目","：可以選「比較難、但能學到東西」的，或是「簡單、一定做得好」的。被誇努力的，絕大多數選了難的；被誇聰明的，大多選了簡單的。",[23,8009,8010],{},"被說聰明之後，他們開始保護「聰明」這個標籤",[69,8012,8013,8016],{},[23,8014,8015],{},"一份很難的題目","：被誇努力的覺得有挑戰、很投入；被誇聰明的很快就灰心，覺得「原來我沒那麼聰明」",[69,8018,8019,8022],{},[23,8020,8021],{},"可以看別人的考卷","：被誇努力的想看考得比自己好的人，想知道人家怎麼做；被誇聰明的選擇看考得比自己差的，讓自己好過一點",[69,8024,8025,8028],{},[23,8026,8027],{},"再做一次簡單的題目","：被誇努力的成績進步了，被誇聰明的成績反而下降，比第一次還差",[69,8030,8031,8034,8035],{},[23,8032,8033],{},"回報自己的分數","：被誇聰明的那組，有相當比例的人",[23,8036,8037],{},"謊報了分數",[11,8039,8040],{},"一開始兩組的程度完全一樣，差別只在一句話。而那句聽起來是好話的「你好聰明」，讓孩子避開挑戰、害怕失敗、甚至說謊。",[11,8042,8043],{},"看到這裡我第一個想到的是工作上的場景：",[66,8045,8046,8049,8052],{},[69,8047,8048],{},"「這個 Bug 只有他修得好，他是天才」→ 他之後還敢承認自己不會嗎？",[69,8050,8051],{},"「這次 Sprint 做得很完美」→ 下次 Sprint，團隊會不會開始只接有把握的工作？",[69,8053,8054],{},"「Velocity 又創新高」→ 估算會不會開始悄悄膨脹？",[28,8056,8057],{"id":8057},"組織也有思維",[11,8059,8060],{},"Linda Rising 接著把這件事放大到組織。",[100,8062,8064],{"id":8063},"enron-與西南航空","Enron 與西南航空",[11,8066,8067,8070,8071,8073],{},[23,8068,8069],{},"Enron"," 是她舉的固定型思維組織。Enron 以「只雇用最頂尖的人才」聞名，並且定期把員工排名，淘汰後段的人（俗稱 rank and yank）。在這種環境下，每個人最重要的事是",[23,8072,7916],{},"：不能犯錯、不能承認不懂、壞消息要藏起來。Malcolm Gladwell 在〈The Talent Myth〉一文中就認為，這種對「天才」的迷信，是 Enron 最後崩潰的原因之一。",[11,8075,8076,8079,8080,8083],{},[23,8077,8078],{},"西南航空（Southwest Airlines）"," 則是相反的例子：他們",[23,8081,8082],{},"依照態度雇人","，再投資在員工的學習與成長上，建立互相信任的文化。",[100,8085,8086],{"id":8086},"醫院導入新手術",[11,8088,8089,8090,55],{},"另一個例子來自哈佛商學院 Amy Edmondson 的研究：多家醫院導入同一種新的心臟手術技術，有的成功、有的失敗。差別不在醫院的規模或醫師的名氣，而在",[23,8091,8092],{},"怎麼看待這件事",[66,8094,8095,8101],{},[69,8096,8097,8100],{},[23,8098,8099],{},"成功的醫院","把它當成一次學習，預期一開始會很辛苦，團隊願意一起練習、一起檢討",[69,8102,8103,8106],{},[23,8104,8105],{},"失敗的醫院","把它當成行銷亮點或身分的象徵，認為「我們的醫師那麼強，當然做得到」",[11,8108,8109],{},"同一個技術，不同的思維，完全不同的結果。",[28,8111,8112],{"id":8112},"放回軟體開發",[11,8114,8115],{},"演講最後回到軟體開發。她的幾個觀點我很認同：",[66,8117,8118,8124,8130],{},[69,8119,8120,8123],{},[23,8121,8122],{},"早點失敗、常常失敗、持續學習","：迭代開發本來就是假設我們一開始不會做對，所以要用很短的週期去驗證",[69,8125,8126,8129],{},[23,8127,8128],{},"估算","：研究顯示人普遍會高估自己的表現，即使手上有過去的數據也一樣，而固定型思維的人更嚴重。估算失準不是能力問題，是需要一次次回饋去校正的",[69,8131,8132,8135],{},[23,8133,8134],{},"Perfect is a verb","：「完美」是一個動詞，是持續在做的事，不是一個要達到的終點。敏捷流程本身也是這樣，永遠在演進",[11,8137,8138],{},"她最後引用了 Samuel Beckett 的一句話作結：",[7381,8140,8141],{},[11,8142,8143],{},"Try again. Fail again. Fail better.",[28,8145,8147],{"id":8146},"我的看法敏捷是思維不是手法","我的看法：敏捷是思維，不是手法",[11,8149,8150,8151],{},"看完這場演講，我更確定一件事：",[23,8152,8153],{},"敏捷的核心是思維，Scrum 只是其中一種手法。",[11,8155,8156],{},"其實這件事寫在最源頭的地方。2001 年的《敏捷宣言》第一條就是：",[7381,8158,8159],{},[11,8160,8161,8164],{},[23,8162,8163],{},"個人與互動"," 重於 流程與工具",[11,8166,8167],{},"Scrum 是流程，Jira 是工具。宣言一開始就說了，它們是次要的。",[100,8169,8171],{"id":8170},"同樣的手法不同的思維","同樣的手法，不同的思維",[11,8173,8174],{},"用固定型思維去跑 Scrum，每個事件都會變質：",[11,8176,8177],{},[1952,8178],{"alt":8179,"src":8180},"同樣的 Scrum 事件，在兩種思維下的樣子：Daily Scrum 在固定型思維下變成向主管報告進度、證明自己有在做事，在敏捷思維下是團隊一起看離目標還多遠、誰需要幫忙；Sprint Review 在固定型思維下是展示成果、只講好的，在敏捷思維下是收集回饋、願意聽到「這不是我們要的」；Retrospective 在固定型思維下是找戰犯或每次講一樣的問題，在敏捷思維下是找出下一個要試的改進；估算與 Velocity 在固定型思維下變成績效指標、點數悄悄膨脹，在敏捷思維下是團隊自己預測用的工具；Sprint 目標沒達成時，固定型思維想辦法掩飾或怪別人，敏捷思維問我們學到什麼、下次怎麼調整。結論：手法相同，結果取決於思維","\u002Fimages\u002Farticles\u002Fagile-mindset-same-ceremony.svg",[11,8182,8183,8184,8187,8188,114],{},"這些問題在 ",[119,8185,8186],{"href":7834},"Scrum 那篇"," 的「常見誤解」裡都出現過：站會變成進度報告、用故事點考核人、Retrospective 講了沒改。當時我把它們當成「Scrum 沒有做對」，現在回頭看，",[23,8189,8190],{},"它們其實都是思維的問題，換再多手法也修不好",[100,8192,8194],{"id":8193},"有思維的人不太需要手法","有思維的人，不太需要手法",[11,8196,8197],{},"反過來，我遇過一些從沒上過敏捷課程的工程師，做事方式卻非常敏捷：",[66,8199,8200,8203,8206,8209],{},[69,8201,8202],{},"需求不清楚時，先做一個最小的版本拿去問人，而不是自己猜完整個系統",[69,8204,8205],{},"犯了錯會主動講出來，順便講「我下次會怎麼避免」",[69,8207,8208],{},"卡住時不會硬撐，會去問比自己懂的人",[69,8210,8211],{},"做完一件事會回頭想「哪裡可以更好」，不需要有人排一場 Retrospective 提醒他",[11,8213,8214,8215,8218],{},"這些都是 Scrum 想要透過事件「逼」出來的行為，但對有敏捷思維的人來說，",[23,8216,8217],{},"這本來就是他的工作方式","。Daily Scrum 是為了讓團隊經常溝通，他本來就會溝通；Retrospective 是為了讓團隊持續改進，他本來就在改進。",[11,8220,8221,8222],{},"所以我的看法是：",[23,8223,8224],{},"有思維的人，手法是自然長出來的；沒有思維的人，手法只會變成儀式。",[100,8226,8228],{"id":8227},"那手法還有用嗎","那手法還有用嗎？",[11,8230,8231,8232,55],{},"有，但要擺對位置。我覺得手法比較像",[23,8233,8234],{},"腳踏車的輔助輪",[66,8236,8237,8243,8249],{},[69,8238,8239,8242],{},[23,8240,8241],{},"給團隊一個共同的節奏和語言","：每個人對敏捷的理解都不一樣，Scrum 讓大家至少知道什麼時候該對齊、該檢討",[69,8244,8245,8248],{},[23,8246,8247],{},"讓還沒有這種習慣的人，先有機會體驗","：第一次在 Retrospective 說出問題、看到下個 Sprint 真的改了，可能就是思維開始改變的時候",[69,8250,8251,8254],{},[23,8252,8253],{},"但輔助輪不是目的","：如果團隊已經會騎了，硬要保留每一個儀式、每一份表單，就是本末倒置",[11,8256,8257],{},"當手法和思維衝突時，例如「Scrum Guide 說 Sprint Backlog 不該改，但我們發現方向錯了」，應該回到思維去判斷，而不是拿手法來壓人。",[28,8259,8260],{"id":8260},"怎麼培養敏捷思維",[11,8262,8263,8264,8267],{},"Linda Rising 在演講中提到一個好消息：",[23,8265,8266],{},"思維是可以改變的","，而且有時候只需要很小的介入，例如一次回饋、讀一篇文章。以下是我覺得在團隊裡可以做的：",[66,8269,8270,8276,8282,8288,8294],{},[69,8271,8272,8275],{},[23,8273,8274],{},"稱讚過程，不要只稱讚天分","：「你這次先寫測試再改，所以很快找到問題」比「你好厲害」有用",[69,8277,8278,8281],{},[23,8279,8280],{},"把「失敗」改成「還沒」","：「這個功能還沒做好」和「這個功能失敗了」，給人的感覺完全不同",[69,8283,8284,8287],{},[23,8285,8286],{},"用小實驗代替大決定","：想導入新做法，先在一個 Sprint 試試看，看結果再決定。實驗失敗也是一種結果",[69,8289,8290,8293],{},[23,8291,8292],{},"讓說出問題的人被感謝，而不是被追究","：Retrospective 或事故檢討只要變成找戰犯，下次就沒人敢說了",[69,8295,8296,8299],{},[23,8297,8298],{},"主管要先示範","：主管願意說「這是我判斷錯了」，團隊才敢說",[28,8301,1890],{"id":1890},[11,8303,8304],{},"Linda Rising 用 Dweck 的實驗告訴我們，人怎麼看待能力，會決定他是迎向挑戰還是逃避挑戰、把失敗當成資訊還是當成判決；敏捷的核心正是這種相信能力可以成長、把失敗當成學習的思維。Scrum 這類手法可以當成輔助輪，幫團隊建立節奏，但手法本身不會讓人變敏捷；有了思維，手法自然會被用對，甚至不再需要。",{"title":44,"searchDepth":386,"depth":386,"links":8306},[8307,8308,8309,8313,8314,8319,8320],{"id":7867,"depth":386,"text":7868},{"id":7977,"depth":386,"text":7978},{"id":8057,"depth":386,"text":8057,"children":8310},[8311,8312],{"id":8063,"depth":397,"text":8064},{"id":8086,"depth":397,"text":8086},{"id":8112,"depth":386,"text":8112},{"id":8146,"depth":386,"text":8147,"children":8315},[8316,8317,8318],{"id":8170,"depth":397,"text":8171},{"id":8193,"depth":397,"text":8194},{"id":8227,"depth":397,"text":8228},{"id":8260,"depth":386,"text":8260},{"id":1890,"depth":386,"text":1890},"2025-01-14","過去一年我花了很多時間在敏捷相關的東西上：整理 Scrum 的流程、研究 Event Storming，也試著 把 Event Storming 放進 Scrum。越研究，我越常想起一個問題：",{},"\u002Farticles\u002Fagile-mindset",{"title":7826,"description":8322},"articles\u002Fagile-mindset",[8328,8329,8330,8331,8332],"敏捷開發","Agile Mindset","Scrum","團隊協作","軟體工程","ExfBxXD9E5-QRZpweoT1wh1J-gKF4SSs9G1l_TnsXJ4",{"id":8335,"title":8336,"body":8337,"date":8957,"description":8958,"excerpt":1156,"extension":1157,"meta":8959,"navigation":1035,"path":7839,"seo":8960,"stem":8961,"tags":8962,"__hash__":8966},"articles\u002Farticles\u002Fevent-storming-in-scrum.md","在 Scrum 中加入 Event Storming：Big Picture 建立 Product Backlog，Process Modeling 拆出 User Story",{"type":8,"value":8338,"toc":8944},[8339,8350,8364,8370,8374,8380,8470,8474,8477,8488,8491,8522,8529,8533,8536,8540,8546,8552,8626,8629,8655,8658,8661,8664,8684,8687,8732,8744,8748,8751,8770,8773,8793,8796,8799,8825,8828,8853,8856,8939,8941],[11,8340,8341,8344,8345,8349],{},[119,8342,8343],{"href":7834},"Scrum 流程整理"," 那篇整理了 Scrum 的角色、事件和產出物，",[119,8346,8348],{"href":8347},"\u002Farticles\u002Fevent-storming-workshop","Event Storming 怎麼跑"," 那篇整理了工作坊的做法。兩者各自都清楚，但放在一起時有個落差：",[66,8351,8352,8358],{},[69,8353,8354,8357],{},[23,8355,8356],{},"Scrum 沒有說需求從哪裡來","：Product Backlog 要「排好優先順序、上面的項目要清楚」，但 PO 怎麼知道該有哪些項目？一個個獨立寫出來的 User Story，很容易看不出彼此的關係，Refinement 變成 PO 逐張念卡片",[69,8359,8360,8363],{},[23,8361,8362],{},"Event Storming 沒有說之後怎麼辦","：牆貼滿了、熱點也找到了，然後呢？如果要先把所有流程都拆到 Software Design 才開始開發，那就變回瀑布了",[11,8365,8366,8367],{},"這篇整理把兩者接起來的方式。核心原則只有一句：",[23,8368,8369],{},"Big Picture 看全貌只做一次，細節只為接下來一兩個 Sprint 建模。",[28,8371,8373],{"id":8372},"每種-event-storming-放在哪裡","每種 Event Storming 放在哪裡",[11,8375,8376],{},[1952,8377],{"alt":8378,"src":8379},"Event Storming 在 Scrum 中的位置：產品啟動或設定新的 Product Goal 時，跑一次半天到一天的 Big Picture，產出 Epic、熱點與 Product Goal，進入 Product Backlog；之後每個 Sprint 重複：Backlog Refinement 時針對下一個 Epic 跑 1 到 2 小時的 Process Modeling，拆出 User Story 與驗收條件，放回 Product Backlog；Sprint Planning 時只針對複雜的 Story 跑 30 到 60 分鐘的 Software Design，找出聚合並拆出 Task；開發期間把牆面當參考，新發現的問題貼成熱點；Sprint Review 時在牆面上標出這次完成的事件，和利害關係人對齊全貌；Review 結束後進入下一個 Sprint","\u002Fimages\u002Farticles\u002Fevent-storming-scrum-cadence.svg",[132,8381,8382,8398],{},[135,8383,8384],{},[138,8385,8386,8389,8392,8395],{},[141,8387,8388],{},"時機",[141,8390,8391],{},"Event Storming 層次",[141,8393,8394],{},"參與者",[141,8396,8397],{},"產出",[152,8399,8400,8414,8428,8442,8456],{},[138,8401,8402,8405,8408,8411],{},[157,8403,8404],{},"產品啟動、設定新的 Product Goal",[157,8406,8407],{},"Big Picture（半天～一天）",[157,8409,8410],{},"PO、各部門的領域專家、Developers",[157,8412,8413],{},"Epic、熱點、Product Goal 的方向",[138,8415,8416,8419,8422,8425],{},[157,8417,8418],{},"Backlog Refinement",[157,8420,8421],{},"Process Modeling（1～2 小時）",[157,8423,8424],{},"PO、相關的領域專家、Developers",[157,8426,8427],{},"User Story、驗收條件、還沒釐清的熱點",[138,8429,8430,8433,8436,8439],{},[157,8431,8432],{},"Sprint Planning 的「How」",[157,8434,8435],{},"Software Design（30～60 分鐘，視需要）",[157,8437,8438],{},"Developers，PO 在場回答問題",[157,8440,8441],{},"聚合、命令、事件的設計，以及 Task",[138,8443,8444,8447,8450,8453],{},[157,8445,8446],{},"Sprint 進行中",[157,8448,8449],{},"不另外跑，牆面當參考",[157,8451,8452],{},"Developers",[157,8454,8455],{},"新的熱點",[138,8457,8458,8461,8464,8467],{},[157,8459,8460],{},"Sprint Review",[157,8462,8463],{},"回到牆面更新",[157,8465,8466],{},"Scrum Team、利害關係人",[157,8468,8469],{},"已完成的範圍、對全貌的新理解",[28,8471,8473],{"id":8472},"big-picture-product-backlog","Big Picture → Product Backlog",[11,8475,8476],{},"Big Picture 不需要每個 Sprint 跑，通常在這些時候才跑：",[66,8478,8479,8482,8485],{},[69,8480,8481],{},"新產品或新專案開始",[69,8483,8484],{},"要設定新的 Product Goal，例如「讓斡旋流程全面線上化」",[69,8486,8487],{},"團隊接手一個不熟悉的領域",[11,8489,8490],{},"跑完之後，牆上的東西可以直接對應到 Product Backlog：",[66,8492,8493,8504,8510,8516],{},[69,8494,8495,8498,8499,8503],{},[23,8496,8497],{},"關鍵事件切出來的每一段，變成一個 Epic","：例如「委託與刊登」、「帶看」、「斡旋」、「簽約交屋」。這些段落也是 ",[119,8500,8502],{"href":8501},"\u002Farticles\u002Fddd-subdomains-bounded-context","子域與限界上下文"," 的候選邊界，Epic 和團隊的分工可以一起考慮",[69,8505,8506,8509],{},[23,8507,8508],{},"投票選出的熱點，決定先做哪個 Epic","：大家最痛的地方通常就是最有價值的地方。例如投票最多的是「斡旋要經紀人逐一打電話通知，常常漏掉」，那斡旋就是第一個 Epic",[69,8511,8512,8515],{},[23,8513,8514],{},"問題型的熱點，變成待釐清的項目","：「斡旋已接受算不算成交？」不是開發工作，而是要 PO 去和業務、財務確認的事。可以放進 Product Backlog 並指定負責人，或者在需要時排一個探索性的 Spike",[69,8517,8518,8521],{},[23,8519,8520],{},"Product Goal","：整面牆和投票結果，讓 PO 更容易寫出一個具體的 Product Goal，而不是「優化交易流程」這種口號",[11,8523,8524,8525,8528],{},"這時候 Product Backlog 上只有幾個 Epic，",[23,8526,8527],{},"不需要把每個 Epic 都拆成 Story","。拆得太早，到真的要做時需求可能已經變了。",[28,8530,8532],{"id":8531},"process-modeling-user-story","Process Modeling → User Story",[11,8534,8535],{},"每個 Sprint 的 Backlog Refinement 中，挑出排在最前面、下一兩個 Sprint 就要做的 Epic，跑一場 Process Modeling。Scrum Guide 2017 版曾建議 Refinement 不要超過 Developers 產能的 10%，兩週的 Sprint 大約一天左右，花 1～2 小時跑 Process Modeling 是合理的。",[100,8537,8539],{"id":8538},"沿著命令和事件切-story","沿著命令和事件切 Story",[11,8541,8542,8543,55],{},"Process Modeling 的牆貼完後，",[23,8544,8545],{},"一條「命令 → 事件」或「政策 → 事件」的路徑，就是一個 Story 的候選",[11,8547,8548],{},[1952,8549],{"alt":8550,"src":8551},"把斡旋流程的 Process Modeling 切成 User Story：上方是牆面，依序是命令「提出斡旋」與事件「斡旋已提出」組成 Story A 提出斡旋；政策「每當斡旋已提出就通知屋主」與事件「屋主已收到通知」組成 Story B 通知屋主；角色屋主發出命令「接受斡旋」產生事件「斡旋已接受」組成 Story C 屋主接受斡旋；另一條路徑政策「每當期限到就失效」與事件「斡旋已過期」組成 Story D 斡旋到期失效；政策「每當斡旋已接受就通知其他斡旋人」旁邊貼著熱點「多組斡旋時誰優先」，所以 Story E 還沒就緒，暫時不放進 Sprint。下方展開 Story C，把相關的事件寫成 Given When Then 的驗收條件：斡旋已提出且未過期時接受，結果是斡旋已接受；斡旋已過期時接受，結果是拒絕並提示斡旋已過期；斡旋已撤回時接受，結果是拒絕並提示斡旋已撤回","\u002Fimages\u002Farticles\u002Fevent-storming-story-slicing.svg",[132,8553,8554,8566],{},[135,8555,8556],{},[138,8557,8558,8561,8564],{},[141,8559,8560],{},"Story",[141,8562,8563],{},"牆上的路徑",[141,8565,286],{},[152,8567,8568,8579,8590,8601,8612],{},[138,8569,8570,8573,8576],{},[157,8571,8572],{},"A. 提出斡旋",[157,8574,8575],{},"提出斡旋 → 斡旋已提出",[157,8577,8578],{},"買方能提出斡旋",[138,8580,8581,8584,8587],{},[157,8582,8583],{},"B. 通知屋主",[157,8585,8586],{},"政策：每當斡旋已提出，就通知屋主 → 屋主已收到通知",[157,8588,8589],{},"自動通知取代經紀人打電話",[138,8591,8592,8595,8598],{},[157,8593,8594],{},"C. 屋主接受斡旋",[157,8596,8597],{},"接受斡旋 → 斡旋已接受",[157,8599,8600],{},"屋主可以接受斡旋",[138,8602,8603,8606,8609],{},[157,8604,8605],{},"D. 斡旋到期失效",[157,8607,8608],{},"政策：每當期限到，就失效 → 斡旋已過期",[157,8610,8611],{},"不需要人工處理過期",[138,8613,8614,8617,8620],{},[157,8615,8616],{},"E. 通知其他斡旋人",[157,8618,8619],{},"政策：每當斡旋已接受，就通知其他斡旋人",[157,8621,8622,8623],{},"有熱點「多組斡旋時誰優先」，",[23,8624,8625],{},"還沒就緒",[11,8627,8628],{},"這樣切出來的 Story 有幾個好處：",[66,8630,8631,8637,8643,8649],{},[69,8632,8633,8636],{},[23,8634,8635],{},"天生就是垂直切片","：每個 Story 從命令到事件，一定包含畫面、API、規則、資料，完成後就是一個能用的功能，而不是「斡旋的資料表」、「斡旋的 API」這種依技術層切的 Story",[69,8638,8639,8642],{},[23,8640,8641],{},"大小比較一致","：每條路徑的範圍差不多，估算時比較不會出現一個 3 點、一個 40 點",[69,8644,8645,8648],{},[23,8646,8647],{},"順序很清楚","：牆上看得出 C 一定要在 A 之後，B 和 D 可以並行",[69,8650,8651,8654],{},[23,8652,8653],{},"有熱點的就是還沒就緒","：E 牽涉到一個還沒定案的業務規則，不應該放進 Sprint。這和 Scrum 那篇提到的 Definition of Ready 是同一件事",[11,8656,8657],{},"如果一條路徑太大，例如「提出斡旋」要處理斡旋金的線上付款，可以再沿著事件拆開：「提出斡旋（斡旋金現場收取）」先做，「斡旋金線上付款」之後再做，兩個都是能獨立使用的功能。",[100,8659,8660],{"id":8660},"事件就是驗收條件",[11,8662,8663],{},"Story 的驗收條件，可以直接從牆上的事件寫成 Given–When–Then：",[66,8665,8666,8672,8678],{},[69,8667,8668,8671],{},[23,8669,8670],{},"Given"," 前面已經發生的事件（狀態）",[69,8673,8674,8677],{},[23,8675,8676],{},"When"," 這個 Story 的命令",[69,8679,8680,8683],{},[23,8681,8682],{},"Then"," 應該產生的事件，或是被拒絕的原因",[11,8685,8686],{},"以 Story C「屋主接受斡旋」為例：",[132,8688,8689,8699],{},[135,8690,8691],{},[138,8692,8693,8695,8697],{},[141,8694,8670],{},[141,8696,8676],{},[141,8698,8682],{},[152,8700,8701,8712,8722],{},[138,8702,8703,8706,8709],{},[157,8704,8705],{},"斡旋已提出，且還沒過期",[157,8707,8708],{},"屋主接受斡旋",[157,8710,8711],{},"斡旋已接受",[138,8713,8714,8717,8719],{},[157,8715,8716],{},"斡旋已過期",[157,8718,8708],{},[157,8720,8721],{},"拒絕，提示「斡旋已過期」",[138,8723,8724,8727,8729],{},[157,8725,8726],{},"斡旋已撤回",[157,8728,8708],{},[157,8730,8731],{},"拒絕，提示「斡旋已撤回」",[11,8733,8734,8735,8739,8740,8743],{},"這些驗收條件用的都是牆上的業務用語，PO 看得懂，開發人員也可以直接寫成測試。如果用 ",[119,8736,8738],{"href":8737},"\u002Farticles\u002Fddd-overview-strategic-tactical","DDD 概論"," 裡的聚合設計，這三列幾乎就是 ",[15,8741,8742],{},"Offer.Accept()"," 的三個單元測試。",[28,8745,8747],{"id":8746},"sprint-planning需要時才跑-software-design","Sprint Planning：需要時才跑 Software Design",[11,8749,8750],{},"Sprint Planning 的「How」階段，大部分的 Story 直接拆 Task 就好。只有在這些情況，才值得花 30～60 分鐘跑一次 Software Design：",[66,8752,8753,8764,8767],{},[69,8754,8755,8756,8759,8760,8763],{},"這個 Sprint 要建立新的聚合，例如第一次做斡旋，要決定 ",[15,8757,8758],{},"Offer"," 和 ",[15,8761,8762],{},"Listing"," 的邊界",[69,8765,8766],{},"Story 牽涉到多個聚合之間的一致性，例如「斡旋已接受」之後，物件狀態要不要立刻更新",[69,8768,8769],{},"團隊對設計有明顯的分歧",[11,8771,8772],{},"跑完之後，Task 也很自然地出來了：",[211,8774,8775,8784,8787,8790],{},[69,8776,8777,8779,8780,8783],{},[15,8778,8758],{}," 聚合加上 ",[15,8781,8782],{},"Accept()"," 方法與驗收條件對應的單元測試",[69,8785,8786],{},"「接受斡旋」的命令處理",[69,8788,8789],{},"「斡旋已接受」事件的發布",[69,8791,8792],{},"屋主端的斡旋列表畫面（讀取模型）",[28,8794,8795],{"id":8795},"讓牆面活著",[11,8797,8798],{},"Event Storming 的牆如果貼完就收起來，幾個 Sprint 之後就沒人記得全貌了。幾個讓它持續發揮作用的做法：",[66,8800,8801,8807,8813,8819],{},[69,8802,8803,8806],{},[23,8804,8805],{},"轉成線上白板","：實體牆拍照後整理到 Miro，整個團隊都看得到",[69,8808,8809,8812],{},[23,8810,8811],{},"標出進度","：每個 Sprint 完成的事件，在白板上標記為已完成。Sprint Review 時打開這張圖，利害關係人一眼就能看出這次的 Increment 在整個流程的哪一段",[69,8814,8815,8818],{},[23,8816,8817],{},"新的發現就更新","：開發過程中發現新的規則或例外，直接加一張便利貼或熱點，下次 Refinement 時一起討論",[69,8820,8821,8824],{},[23,8822,8823],{},"不要追求完整","：牆面是溝通工具，不是規格書。過時的部分寧可刪掉，也不要讓它誤導人",[28,8826,8827],{"id":8827},"角色分工",[66,8829,8830,8836,8842,8847],{},[69,8831,8832,8835],{},[23,8833,8834],{},"PO","：對結果負責，決定哪些熱點要優先處理、哪些 Story 先做。Big Picture 和 Process Modeling 都一定要參加",[69,8837,8838,8841],{},[23,8839,8840],{},"Scrum Master","：適合擔任主持人，確保時間盒、確保每個人都有參與。如果團隊裡有人更熟悉 Event Storming，也可以由他主持",[69,8843,8844,8846],{},[23,8845,8452],{},"：一起參與，不是等 PO 把 Story 寫好再看。他們會問出很多業務沒想過的邊界情況",[69,8848,8849,8852],{},[23,8850,8851],{},"領域專家","：Big Picture 要找齊各部門；Process Modeling 只需要該流程的人。他們不一定是 Scrum Team 的成員，由 PO 邀請",[28,8854,8855],{"id":8855},"常見的陷阱",[132,8857,8858,8871],{},[135,8859,8860],{},[138,8861,8862,8865,8868],{},[141,8863,8864],{},"陷阱",[141,8866,8867],{},"結果",[141,8869,8870],{},"改善",[152,8872,8873,8884,8895,8906,8917,8928],{},[138,8874,8875,8878,8881],{},[157,8876,8877],{},"開發前把所有流程都跑到 Software Design",[157,8879,8880],{},"花了一個月設計，需求已經變了，變回瀑布",[157,8882,8883],{},"只為接下來一兩個 Sprint 建模",[138,8885,8886,8889,8892],{},[157,8887,8888],{},"只有 Developers 自己跑",[157,8890,8891],{},"牆上都是開發人員的猜測，沒有真正的業務知識",[157,8893,8894],{},"PO 和領域專家一定要在場",[138,8896,8897,8900,8903],{},[157,8898,8899],{},"依技術層切 Story",[157,8901,8902],{},"每個 Story 都無法單獨使用，Sprint Review 沒東西可展示",[157,8904,8905],{},"沿著命令→事件的路徑切",[138,8907,8908,8911,8914],{},[157,8909,8910],{},"熱點沒有人追",[157,8912,8913],{},"同樣的問題在下一場又被貼一次",[157,8915,8916],{},"每個熱點指定負責人，放進 Product Backlog",[138,8918,8919,8922,8925],{},[157,8920,8921],{},"每個 Sprint 都跑 Big Picture",[157,8923,8924],{},"時間成本太高，參與者開始疲乏",[157,8926,8927],{},"Big Picture 只在 Product Goal 改變時跑",[138,8929,8930,8933,8936],{},[157,8931,8932],{},"牆面只用一次",[157,8934,8935],{},"幾個 Sprint 後沒人記得全貌",[157,8937,8938],{},"轉成線上白板，在 Review 時更新",[28,8940,1890],{"id":1890},[11,8942,8943],{},"把 Event Storming 加進 Scrum 的方式，是依照「由寬到窄、剛好夠用」放在不同的時機：產品啟動時用 Big Picture 找出 Epic、熱點與 Product Goal，每個 Sprint 的 Refinement 只為下一個 Epic 跑 Process Modeling，沿著命令與事件切出垂直的 User Story、用事件寫出驗收條件，Sprint Planning 只在必要時跑 Software Design，並讓牆面在每次 Review 時持續更新。",{"title":44,"searchDepth":386,"depth":386,"links":8945},[8946,8947,8948,8952,8953,8954,8955,8956],{"id":8372,"depth":386,"text":8373},{"id":8472,"depth":386,"text":8473},{"id":8531,"depth":386,"text":8532,"children":8949},[8950,8951],{"id":8538,"depth":397,"text":8539},{"id":8660,"depth":397,"text":8660},{"id":8746,"depth":386,"text":8747},{"id":8795,"depth":386,"text":8795},{"id":8827,"depth":386,"text":8827},{"id":8855,"depth":386,"text":8855},{"id":1890,"depth":386,"text":1890},"2024-12-17","Scrum 流程整理 那篇整理了 Scrum 的角色、事件和產出物，Event Storming 怎麼跑 那篇整理了工作坊的做法。兩者各自都清楚，但放在一起時有個落差：",{},{"title":8336,"description":8958},"articles\u002Fevent-storming-in-scrum",[8330,8963,8964,8328,8965],"Event Storming","DDD","需求分析","ymb4RS9-jg49dw3-0TenpzRiFqG8-w_H4_INzJS5JNc",{"id":8968,"title":8969,"body":8970,"date":10096,"description":44,"excerpt":1156,"extension":1157,"meta":10097,"navigation":1035,"path":10098,"seo":10099,"stem":10100,"tags":10101,"__hash__":10105},"articles\u002Farticles\u002Fredis-distributed-lock.md","用 Redis 做分散式鎖：SET NX PX、Token 驗證、續期，以及它保證不了的事",{"type":8,"value":8971,"toc":10080},[8972,8975,8992,8995,9022,9029,9033,9036,9042,9060,9078,9082,9089,9095,9122,9133,9170,9174,9181,9189,9200,9231,9235,9252,9659,9662,9740,9753,9756,9763,9766,9777,9780,9783,9794,9808,9812,9827,9887,9894,9898,9946,9949,9978,9985,9989,10025,10027,10068,10070,10077],[28,8973,8974],{"id":8974},"為什麼需要分散式鎖",[11,8976,8977,8978,2390,8981,8984,8985,8988,8989,8991],{},"在單一應用程式裡，要確保同一段程式不會同時被執行，用 ",[15,8979,8980],{},"lock",[15,8982,8983],{},"SemaphoreSlim"," 就可以了。但部署到 K8s、開了 3 個 Pod 之後，",[23,8986,8987],{},"每個 Pod 都是獨立的行程","，各自的 ",[15,8990,8980],{}," 只鎖得住自己，管不到其他 Pod。",[11,8993,8994],{},"常見的情境：",[66,8996,8997,9010,9016],{},[69,8998,8999,9002,9003,9006,9007],{},[23,9000,9001],{},"排程只能跑一次","：每天凌晨的結算工作寫在 ",[15,9004,9005],{},"BackgroundService"," 裡，3 個 Pod 時間一到就",[23,9008,9009],{},"同時執行 3 次",[69,9011,9012,9015],{},[23,9013,9014],{},"同一筆資料不能被同時處理","：同一張訂單同時收到兩個「付款完成」的通知，兩個 Pod 各處理一次，出貨兩次",[69,9017,9018,9021],{},[23,9019,9020],{},"呼叫有次數限制的外部 API","：多個實例同時重新取得 Token，把對方的 Token 刷掉",[11,9023,9024,9025,9028],{},"這時候需要一個",[23,9026,9027],{},"所有實例都看得到","的地方來放鎖，Redis 是最常見的選擇：快、每個指令都是原子的，而且大部分系統本來就有一台。",[28,9030,9032],{"id":9031},"第一版set-nx-px","第一版：SET NX PX",[11,9034,9035],{},"搶鎖的核心是一個指令：",[36,9037,9040],{"className":9038,"code":9039,"language":41,"meta":44},[39],"SET lock:settlement:2024-11-26 {token} NX PX 30000\n",[15,9041,9039],{"__ignoreMap":44},[66,9043,9044,9054],{},[69,9045,9046,9049,9050,9053],{},[15,9047,9048],{},"NX","：只有 Key ",[23,9051,9052],{},"不存在","時才寫入，存在就失敗——誰先寫入誰就拿到鎖",[69,9055,9056,9059],{},[15,9057,9058],{},"PX 30000","：30 秒後自動過期——拿到鎖的實例當掉了，鎖也會自己釋放，不會永遠卡住",[11,9061,9062,9065,9066,9069,9070,9073,9074,9077],{},[23,9063,9064],{},"這兩個參數一定要在同一個指令裡","。舊的寫法是先 ",[15,9067,9068],{},"SETNX"," 再 ",[15,9071,9072],{},"EXPIRE","，如果在兩個指令之間當掉，這把鎖就",[23,9075,9076],{},"永遠沒有過期時間","，之後誰都拿不到。",[28,9079,9081],{"id":9080},"漏洞一刪到別人的鎖","漏洞一：刪到別人的鎖",[11,9083,9084,9085,9088],{},"最直覺的釋放方式是做完工作後 ",[15,9086,9087],{},"DEL"," 這個 Key。但看看這個時序：",[11,9090,9091],{},[1952,9092],{"alt":9093,"src":9094},"Redis 分散式鎖的兩個時序比較：上方是有問題的做法，Pod A 取得鎖、TTL 30 秒，但工作因為 GC 停頓與資料庫變慢跑了 40 秒，第 30 秒鎖自動過期，Pod B 取得鎖開始執行，此時 A 與 B 同時在做同一件事；第 40 秒 A 做完後直接 DEL，把 B 的鎖刪掉，Pod C 又取得鎖，三個實例同時執行。下方是改善後的做法，每次取鎖時寫入唯一的 Token，背景每 10 秒檢查 Token 後續期，讓 A 在工作期間一直持有鎖；釋放時用 Lua 比對 Token 相同才刪除，B 在 A 釋放後才取得鎖","\u002Fimages\u002Farticles\u002Fredis-lock-timeline.svg",[211,9096,9097,9100,9103,9110,9119],{},[69,9098,9099],{},"Pod A 拿到鎖，過期時間 30 秒",[69,9101,9102],{},"A 的工作因為資料庫變慢，跑了 40 秒",[69,9104,9105,9106,9109],{},"第 30 秒，鎖過期了，",[23,9107,9108],{},"Pod B 拿到鎖","，開始做同一件事",[69,9111,9112,9113,9115,9116],{},"第 40 秒，A 做完了，",[15,9114,9087],{}," 這個 Key——",[23,9117,9118],{},"刪掉的是 B 的鎖",[69,9120,9121],{},"Pod C 接著也拿到鎖，現在 B、C 同時在執行",[11,9123,9124,9125,9128,9129,9132],{},"解法是",[23,9126,9127],{},"每次取鎖時寫入一個唯一的 Token","（例如 GUID），釋放時",[23,9130,9131],{},"確認 Token 是自己的才刪除","。「讀取比對」和「刪除」必須是原子的，否則在兩者之間鎖剛好過期又被別人拿走，還是會刪錯。所以要用 Lua Script，讓 Redis 一次執行完：",[36,9134,9138],{"className":9135,"code":9136,"language":9137,"meta":44,"style":44},"language-lua shiki shiki-themes github-dark","-- 釋放鎖：Token 相同才刪除\nif redis.call(\"GET\", KEYS[1]) == ARGV[1] then\n    return redis.call(\"DEL\", KEYS[1])\nelse\n    return 0\nend\n","lua",[15,9139,9140,9145,9150,9155,9160,9165],{"__ignoreMap":44},[376,9141,9142],{"class":378,"line":379},[376,9143,9144],{},"-- 釋放鎖：Token 相同才刪除\n",[376,9146,9147],{"class":378,"line":386},[376,9148,9149],{},"if redis.call(\"GET\", KEYS[1]) == ARGV[1] then\n",[376,9151,9152],{"class":378,"line":397},[376,9153,9154],{},"    return redis.call(\"DEL\", KEYS[1])\n",[376,9156,9157],{"class":378,"line":405},[376,9158,9159],{},"else\n",[376,9161,9162],{"class":378,"line":418},[376,9163,9164],{},"    return 0\n",[376,9166,9167],{"class":378,"line":429},[376,9168,9169],{},"end\n",[28,9171,9173],{"id":9172},"漏洞二工作還沒做完鎖就過期了","漏洞二：工作還沒做完，鎖就過期了",[11,9175,9176,9177,9180],{},"Token 解決了「刪錯鎖」，但沒解決上面第 3 步：",[23,9178,9179],{},"A 還在做，B 已經拿到鎖了","。過期時間設多長都很難拿捏：",[66,9182,9183,9186],{},[69,9184,9185],{},"設太短：工作偶爾變慢，鎖就提前失效",[69,9187,9188],{},"設太長：實例當掉時，要等很久才會有別人接手",[11,9190,9191,9192,9195,9196,9199],{},"常見的做法是",[23,9193,9194],{},"自動續期（Watchdog）","：拿到鎖之後啟動一個背景計時器，每隔過期時間的三分之一（例如每 10 秒），",[23,9197,9198],{},"確認 Token 還是自己的","，再把過期時間延長回 30 秒。工作做完就停止續期並釋放。實例當掉的話，續期也跟著停止，鎖最多 30 秒後過期。",[36,9201,9203],{"className":9135,"code":9202,"language":9137,"meta":44,"style":44},"-- 續期：Token 相同才延長\nif redis.call(\"GET\", KEYS[1]) == ARGV[1] then\n    return redis.call(\"PEXPIRE\", KEYS[1], ARGV[2])\nelse\n    return 0\nend\n",[15,9204,9205,9210,9214,9219,9223,9227],{"__ignoreMap":44},[376,9206,9207],{"class":378,"line":379},[376,9208,9209],{},"-- 續期：Token 相同才延長\n",[376,9211,9212],{"class":378,"line":386},[376,9213,9149],{},[376,9215,9216],{"class":378,"line":397},[376,9217,9218],{},"    return redis.call(\"PEXPIRE\", KEYS[1], ARGV[2])\n",[376,9220,9221],{"class":378,"line":405},[376,9222,9159],{},[376,9224,9225],{"class":378,"line":418},[376,9226,9164],{},[376,9228,9229],{"class":378,"line":429},[376,9230,9169],{},[28,9232,9234],{"id":9233},"在-net-裡實作","在 .NET 裡實作",[11,9236,9237,9238,9241,9242,1540,9245,8759,9248,9251],{},"StackExchange.Redis 已經內建了上面這三個操作：",[15,9239,9240],{},"LockTakeAsync"," 就是 ",[15,9243,9244],{},"SET NX PX",[15,9246,9247],{},"LockExtendAsync",[15,9249,9250],{},"LockReleaseAsync"," 都會先比對 Token，不用自己寫 Lua。",[36,9253,9255],{"className":3145,"code":9254,"language":3147,"meta":44,"style":44},"public async Task\u003Cbool> RunWithLockAsync(string resource, Func\u003CCancellationToken, Task> work)\n{\n    var key = $\"lock:{resource}\";\n    var token = Guid.NewGuid().ToString(\"N\");\n    var expiry = TimeSpan.FromSeconds(30);\n\n    if (!await _redis.LockTakeAsync(key, token, expiry))\n        return false;  \u002F\u002F 別人正在做\n\n    using var cts = new CancellationTokenSource();\n    var watchdog = Task.Run(async () =>\n    {\n        while (!cts.IsCancellationRequested)\n        {\n            await Task.Delay(expiry \u002F 3, cts.Token);\n            if (!await _redis.LockExtendAsync(key, token, expiry))\n            {\n                \u002F\u002F 續期失敗代表鎖已經不是自己的了，通知工作盡快停下來\n                cts.Cancel();\n                break;\n            }\n        }\n    });\n\n    try\n    {\n        await work(cts.Token);\n        return true;\n    }\n    finally\n    {\n        cts.Cancel();\n        try { await watchdog; } catch (OperationCanceledException) { }\n        await _redis.LockReleaseAsync(key, token);\n    }\n}\n",[15,9256,9257,9303,9307,9330,9357,9378,9382,9399,9411,9415,9435,9458,9462,9473,9477,9499,9514,9519,9524,9534,9541,9546,9550,9555,9559,9563,9567,9579,9588,9593,9598,9603,9613,9637,9649,9654],{"__ignoreMap":44},[376,9258,9259,9261,9263,9265,9267,9270,9272,9275,9277,9279,9282,9284,9287,9289,9291,9293,9296,9298,9301],{"class":378,"line":379},[376,9260,3274],{"class":3196},[376,9262,3277],{"class":3196},[376,9264,3280],{"class":1020},[376,9266,3176],{"class":393},[376,9268,9269],{"class":3196},"bool",[376,9271,3288],{"class":393},[376,9273,9274],{"class":1020},"RunWithLockAsync",[376,9276,3220],{"class":393},[376,9278,5012],{"class":3196},[376,9280,9281],{"class":1020}," resource",[376,9283,3188],{"class":393},[376,9285,9286],{"class":1020},"Func",[376,9288,3176],{"class":393},[376,9290,3307],{"class":1020},[376,9292,3188],{"class":393},[376,9294,9295],{"class":1020},"Task",[376,9297,3288],{"class":393},[376,9299,9300],{"class":1020},"work",[376,9302,3319],{"class":393},[376,9304,9305],{"class":378,"line":386},[376,9306,3324],{"class":393},[376,9308,9309,9311,9314,9316,9319,9321,9324,9326,9328],{"class":378,"line":397},[376,9310,5061],{"class":3196},[376,9312,9313],{"class":1020}," key",[376,9315,3313],{"class":3196},[376,9317,9318],{"class":414}," $\"lock:",[376,9320,4729],{"class":414},[376,9322,9323],{"class":393},"resource",[376,9325,4741],{"class":414},[376,9327,4744],{"class":414},[376,9329,3428],{"class":393},[376,9331,9332,9334,9337,9339,9342,9345,9347,9350,9352,9355],{"class":378,"line":405},[376,9333,5061],{"class":3196},[376,9335,9336],{"class":1020}," token",[376,9338,3313],{"class":3196},[376,9340,9341],{"class":393}," Guid.",[376,9343,9344],{"class":1020},"NewGuid",[376,9346,3526],{"class":393},[376,9348,9349],{"class":1020},"ToString",[376,9351,3220],{"class":393},[376,9353,9354],{"class":414},"\"N\"",[376,9356,3376],{"class":393},[376,9358,9359,9361,9364,9366,9369,9371,9373,9376],{"class":378,"line":418},[376,9360,5061],{"class":3196},[376,9362,9363],{"class":1020}," expiry",[376,9365,3313],{"class":3196},[376,9367,9368],{"class":393}," TimeSpan.",[376,9370,3832],{"class":1020},[376,9372,3220],{"class":393},[376,9374,9375],{"class":560},"30",[376,9377,3376],{"class":393},[376,9379,9380],{"class":378,"line":429},[376,9381,1036],{"emptyLinePlaceholder":1035},[376,9383,9384,9386,9388,9391,9394,9396],{"class":378,"line":437},[376,9385,3477],{"class":3196},[376,9387,5046],{"class":393},[376,9389,9390],{"class":3196},"!await",[376,9392,9393],{"class":393}," _redis.",[376,9395,9240],{"class":1020},[376,9397,9398],{"class":393},"(key, token, expiry))\n",[376,9400,9401,9403,9405,9408],{"class":378,"line":447},[376,9402,3485],{"class":3196},[376,9404,3425],{"class":560},[376,9406,9407],{"class":393},";  ",[376,9409,9410],{"class":382},"\u002F\u002F 別人正在做\n",[376,9412,9413],{"class":378,"line":531},[376,9414,1036],{"emptyLinePlaceholder":1035},[376,9416,9417,9420,9423,9426,9428,9430,9433],{"class":378,"line":542},[376,9418,9419],{"class":3196},"    using",[376,9421,9422],{"class":3196}," var",[376,9424,9425],{"class":1020}," cts",[376,9427,3313],{"class":3196},[376,9429,4692],{"class":3196},[376,9431,9432],{"class":1020}," CancellationTokenSource",[376,9434,3532],{"class":393},[376,9436,9437,9439,9442,9444,9446,9448,9450,9453,9456],{"class":378,"line":552},[376,9438,5061],{"class":3196},[376,9440,9441],{"class":1020}," watchdog",[376,9443,3313],{"class":3196},[376,9445,5668],{"class":393},[376,9447,5671],{"class":1020},[376,9449,3220],{"class":393},[376,9451,9452],{"class":3196},"async",[376,9454,9455],{"class":393}," () ",[376,9457,3778],{"class":3196},[376,9459,9460],{"class":378,"line":564},[376,9461,5704],{"class":393},[376,9463,9464,9466,9468,9470],{"class":378,"line":575},[376,9465,5709],{"class":3196},[376,9467,5046],{"class":393},[376,9469,5049],{"class":3196},[376,9471,9472],{"class":393},"cts.IsCancellationRequested)\n",[376,9474,9475],{"class":378,"line":583},[376,9476,5721],{"class":393},[376,9478,9479,9482,9484,9487,9490,9493,9496],{"class":378,"line":597},[376,9480,9481],{"class":3196},"            await",[376,9483,5668],{"class":393},[376,9485,9486],{"class":1020},"Delay",[376,9488,9489],{"class":393},"(expiry ",[376,9491,9492],{"class":3196},"\u002F",[376,9494,9495],{"class":560}," 3",[376,9497,9498],{"class":393},", cts.Token);\n",[376,9500,9501,9504,9506,9508,9510,9512],{"class":378,"line":605},[376,9502,9503],{"class":3196},"            if",[376,9505,5046],{"class":393},[376,9507,9390],{"class":3196},[376,9509,9393],{"class":393},[376,9511,9247],{"class":1020},[376,9513,9398],{"class":393},[376,9515,9516],{"class":378,"line":616},[376,9517,9518],{"class":393},"            {\n",[376,9520,9521],{"class":378,"line":624},[376,9522,9523],{"class":382},"                \u002F\u002F 續期失敗代表鎖已經不是自己的了，通知工作盡快停下來\n",[376,9525,9526,9529,9532],{"class":378,"line":635},[376,9527,9528],{"class":393},"                cts.",[376,9530,9531],{"class":1020},"Cancel",[376,9533,3532],{"class":393},[376,9535,9536,9539],{"class":378,"line":5815},[376,9537,9538],{"class":3196},"                break",[376,9540,3428],{"class":393},[376,9542,9543],{"class":378,"line":7233},[376,9544,9545],{"class":393},"            }\n",[376,9547,9548],{"class":378,"line":7243},[376,9549,5761],{"class":393},[376,9551,9552],{"class":378,"line":7251},[376,9553,9554],{"class":393},"    });\n",[376,9556,9557],{"class":378,"line":7721},[376,9558,1036],{"emptyLinePlaceholder":1035},[376,9560,9561],{"class":378,"line":7729},[376,9562,5699],{"class":3196},[376,9564,9565],{"class":378,"line":7740},[376,9566,5704],{"class":393},[376,9568,9570,9573,9576],{"class":378,"line":9569},27,[376,9571,9572],{"class":3196},"        await",[376,9574,9575],{"class":1020}," work",[376,9577,9578],{"class":393},"(cts.Token);\n",[376,9580,9582,9584,9586],{"class":378,"line":9581},28,[376,9583,3485],{"class":3196},[376,9585,3802],{"class":560},[376,9587,3428],{"class":393},[376,9589,9591],{"class":378,"line":9590},29,[376,9592,5766],{"class":393},[376,9594,9596],{"class":378,"line":9595},30,[376,9597,5784],{"class":3196},[376,9599,9601],{"class":378,"line":9600},31,[376,9602,5704],{"class":393},[376,9604,9606,9609,9611],{"class":378,"line":9605},32,[376,9607,9608],{"class":393},"        cts.",[376,9610,9531],{"class":1020},[376,9612,3532],{"class":393},[376,9614,9616,9619,9622,9625,9628,9631,9633,9635],{"class":378,"line":9615},33,[376,9617,9618],{"class":3196},"        try",[376,9620,9621],{"class":393}," { ",[376,9623,9624],{"class":3196},"await",[376,9626,9627],{"class":393}," watchdog; } ",[376,9629,9630],{"class":3196},"catch",[376,9632,5046],{"class":393},[376,9634,5776],{"class":1020},[376,9636,5779],{"class":393},[376,9638,9640,9642,9644,9646],{"class":378,"line":9639},34,[376,9641,9572],{"class":3196},[376,9643,9393],{"class":393},[376,9645,9250],{"class":1020},[376,9647,9648],{"class":393},"(key, token);\n",[376,9650,9652],{"class":378,"line":9651},35,[376,9653,5766],{"class":393},[376,9655,9657],{"class":378,"line":9656},36,[376,9658,3381],{"class":393},[11,9660,9661],{},"使用時：",[36,9663,9665],{"className":3145,"code":9664,"language":3147,"meta":44,"style":44},"\u002F\u002F 3 個 Pod 同時觸發，只有一個會真的執行\nvar date = DateTime.Today.ToString(\"yyyy-MM-dd\");\nvar executed = await _lock.RunWithLockAsync($\"settlement:{date}\", ct => _settlement.RunAsync(date, ct));\n",[15,9666,9667,9672,9693],{"__ignoreMap":44},[376,9668,9669],{"class":378,"line":379},[376,9670,9671],{"class":382},"\u002F\u002F 3 個 Pod 同時觸發，只有一個會真的執行\n",[376,9673,9674,9676,9679,9681,9684,9686,9688,9691],{"class":378,"line":386},[376,9675,4684],{"class":3196},[376,9677,9678],{"class":1020}," date",[376,9680,3313],{"class":3196},[376,9682,9683],{"class":393}," DateTime.Today.",[376,9685,9349],{"class":1020},[376,9687,3220],{"class":393},[376,9689,9690],{"class":414},"\"yyyy-MM-dd\"",[376,9692,3376],{"class":393},[376,9694,9695,9697,9700,9702,9705,9708,9710,9712,9715,9717,9720,9722,9724,9726,9729,9731,9734,9737],{"class":378,"line":397},[376,9696,4684],{"class":3196},[376,9698,9699],{"class":1020}," executed",[376,9701,3313],{"class":3196},[376,9703,9704],{"class":3196}," await",[376,9706,9707],{"class":393}," _lock.",[376,9709,9274],{"class":1020},[376,9711,3220],{"class":393},[376,9713,9714],{"class":414},"$\"settlement:",[376,9716,4729],{"class":414},[376,9718,9719],{"class":393},"date",[376,9721,4741],{"class":414},[376,9723,4744],{"class":414},[376,9725,3188],{"class":393},[376,9727,9728],{"class":1020},"ct",[376,9730,3246],{"class":3196},[376,9732,9733],{"class":393}," _settlement.",[376,9735,9736],{"class":1020},"RunAsync",[376,9738,9739],{"class":393},"(date, ct));\n",[11,9741,9742,9743,9745,9746,9748,9749,9752],{},"注意 ",[15,9744,9300],{}," 收到了 ",[15,9747,3307],{},"：續期失敗時會被取消，工作要",[23,9750,9751],{},"檢查這個 Token 並停下來","，不然即使知道鎖已經沒了，程式還是會繼續做下去。",[28,9754,9755],{"id":9755},"它保證不了的事",[11,9757,9758,9759,9762],{},"補完上面這些，Redis 鎖在大部分情況下都能正常運作。但有幾種情況，它",[23,9760,9761],{},"原理上","就無法保證：",[100,9764,9765],{"id":9765},"程序停頓",[11,9767,9768,9769,9772,9773,9776],{},"A 拿到鎖之後，整個行程因為一次很長的 GC、或是 VM 被暫停，",[23,9770,9771],{},"停了 40 秒","。這段時間 Watchdog 也一起停了，沒辦法續期。鎖過期、B 拿到鎖。A 醒來之後，",[23,9774,9775],{},"它不知道自己睡了 40 秒","，繼續寫資料庫。",[11,9778,9779],{},"續期只能縮小這個時間窗，不能消除它。",[100,9781,9782],{"id":9782},"主從切換",[11,9784,9785,9786,9789,9790,9793],{},"Redis 的主從複製是",[23,9787,9788],{},"非同步","的。A 在 Master 上取得鎖，Master 還沒同步給 Replica 就掛了，Replica 被提升為新的 Master——",[23,9791,9792],{},"上面沒有這把鎖","，B 馬上就能拿到。",[11,9795,9796,9797,9800,9801,9804,9805,114],{},"Redis 作者提出的 ",[23,9798,9799],{},"Redlock"," 演算法，是向 5 個",[23,9802,9803],{},"彼此獨立","的 Redis 取鎖，過半數成功才算拿到，以避免單一節點故障。但它依賴「各台機器的時鐘大致準確、停頓不會太久」這類時間上的假設，Martin Kleppmann 在〈How to do distributed locking〉一文中就以程序停頓和時鐘跳動為例，認為它不足以保證正確性。這場討論的結論，對使用者來說很實用：",[23,9806,9807],{},"先想清楚你要的是「效率」還是「正確性」",[100,9809,9811],{"id":9810},"fencing-token","Fencing Token",[11,9813,9814,9815,9818,9819,9822,9823,9826],{},"如果需要正確性，問題要在",[23,9816,9817],{},"被保護的資源那一端","解決。做法是每次取鎖時，再拿一個",[23,9820,9821],{},"單調遞增的號碼","（例如 ",[15,9824,9825],{},"INCR lock:settlement:fence","），寫入資料時帶上這個號碼，資料庫只接受比目前記錄更大的號碼：",[36,9828,9832],{"className":9829,"code":9830,"language":9831,"meta":44,"style":44},"language-sql shiki shiki-themes github-dark","UPDATE Settlement\nSET    Status = 'Done', FenceToken = @token\nWHERE  SettleDate = @date AND FenceToken \u003C @token;\n","sql",[15,9833,9834,9842,9863],{"__ignoreMap":44},[376,9835,9836,9839],{"class":378,"line":379},[376,9837,9838],{"class":3196},"UPDATE",[376,9840,9841],{"class":393}," Settlement\n",[376,9843,9844,9847,9850,9852,9855,9858,9860],{"class":378,"line":386},[376,9845,9846],{"class":3196},"SET",[376,9848,9849],{"class":3196},"    Status",[376,9851,3313],{"class":3196},[376,9853,9854],{"class":414}," 'Done'",[376,9856,9857],{"class":393},", FenceToken ",[376,9859,3240],{"class":3196},[376,9861,9862],{"class":393}," @token\n",[376,9864,9865,9868,9871,9873,9876,9879,9882,9884],{"class":378,"line":397},[376,9866,9867],{"class":3196},"WHERE",[376,9869,9870],{"class":393},"  SettleDate ",[376,9872,3240],{"class":3196},[376,9874,9875],{"class":393}," @date ",[376,9877,9878],{"class":3196},"AND",[376,9880,9881],{"class":393}," FenceToken ",[376,9883,3176],{"class":3196},[376,9885,9886],{"class":393}," @token;\n",[11,9888,9889,9890,9893],{},"A 拿到的是 33、B 拿到的是 34。B 先寫入之後，A 醒來帶著 33 想寫入，條件不成立，",[23,9891,9892],{},"寫入失敗","。這樣即使兩個實例同時以為自己持有鎖，資料也不會被舊的那個覆蓋。",[28,9895,9897],{"id":9896},"效率還是正確性","效率還是正確性？",[132,9899,9900,9913],{},[135,9901,9902],{},[138,9903,9904,9907,9910],{},[141,9905,9906],{},"目的",[141,9908,9909],{},"鎖失效的後果",[141,9911,9912],{},"建議",[152,9914,9915,9929],{},[138,9916,9917,9923,9926],{},[157,9918,9919,9922],{},[23,9920,9921],{},"效率","：避免重複做同一件事",[157,9924,9925],{},"偶爾多做一次，浪費一點資源",[157,9927,9928],{},"Redis 單節點鎖 + Token + 續期就夠了",[138,9930,9931,9937,9940],{},[157,9932,9933,9936],{},[23,9934,9935],{},"正確性","：絕對不能同時做",[157,9938,9939],{},"資料錯誤、重複扣款、重複出貨",[157,9941,9942,9943],{},"Redis 鎖只當第一道防線，",[23,9944,9945],{},"資料庫必須有自己的保護",[11,9947,9948],{},"需要正確性時，真正的保證應該來自資料庫：",[66,9950,9951,9957,9968],{},[69,9952,9953,9956],{},[23,9954,9955],{},"唯一索引","：結算紀錄以日期為唯一鍵，第二次寫入直接失敗",[69,9958,9959,55,9962,2276,9965],{},[23,9960,9961],{},"條件更新／樂觀鎖",[15,9963,9964],{},"WHERE Version = @version",[15,9966,9967],{},"WHERE Status = 'Pending'",[69,9969,9970,9973,9974,4898],{},[23,9971,9972],{},"冪等鍵","：每個請求帶一個唯一 ID，處理過的就不再處理（點數這類情境可以參考 ",[119,9975,9977],{"href":9976},"\u002Farticles\u002Fpoints-ledger-idempotency","點數帳本的冪等鍵與條件更新",[11,9979,9980,9981,9984],{},"Redis 鎖在這裡的角色，是",[23,9982,9983],{},"擋掉絕大多數的重複執行","，讓資料庫的保護機制很少需要被觸發。",[28,9986,9988],{"id":9987},"使用情境多個-pod-的每日結算排程","使用情境：多個 Pod 的每日結算排程",[66,9990,9991,9997,10008,10011,10022],{},[69,9992,9993,9994,9996],{},"結算服務部署 3 個 Pod，每個 Pod 的 ",[15,9995,9005],{}," 都會在凌晨 00:05 觸發",[69,9998,9999,10000,10003,10004,10007],{},"用 ",[15,10001,10002],{},"settlement:{日期}"," 當鎖的名稱，",[23,10005,10006],{},"只有搶到鎖的 Pod 會執行","，其他兩個直接略過",[69,10009,10010],{},"執行中的 Pod 透過 Watchdog 每 10 秒續期；Pod 被重啟或當掉，30 秒後鎖過期，下一個排程週期由其他 Pod 接手",[69,10012,10013,10014,10017,10018,10021],{},"結算結果寫入時，資料表以結算日期為",[23,10015,10016],{},"唯一鍵","，並用 ",[15,10019,10020],{},"Status"," 做條件更新——就算鎖失效、兩個 Pod 都跑了，第二個也寫不進去",[69,10023,10024],{},"鎖的取得、續期失敗、釋放都記錄 Log，續期失敗時告警",[28,10026,4494],{"id":4494},[66,10028,10029,10035,10041,10047,10056,10062],{},[69,10030,10031,10034],{},[23,10032,10033],{},"鎖的名稱要夠精確","：鎖整個「結算」，和鎖「某一天的結算」、「某一個帳號」，平行度完全不同。鎖的範圍越小，越不容易互相等待",[69,10036,10037,10040],{},[23,10038,10039],{},"拿不到鎖時要決定行為","：直接略過（排程）、等待重試（處理同一筆資料），還是回傳錯誤（使用者操作）。等待時要有上限，不要無限輪詢",[69,10042,10043,10046],{},[23,10044,10045],{},"不要在鎖裡做太久的事","：鎖住的時間越長，被停頓、網路問題影響的機率越高。能拆小就拆小",[69,10048,10049,10052,10053,10055],{},[23,10050,10051],{},"不需要跨實例時不要用","：只是要防止同一個 Pod 內的並行，用 ",[15,10054,8983],{}," 就好，多一次 Redis 往返沒有意義",[69,10057,10058,10061],{},[23,10059,10060],{},"Redis 掛掉時的行為要先想好","：拿不到鎖是「全部都不做」還是「降級成都做、靠資料庫擋」，依業務決定，但要事先決定，而不是出事時才發現",[69,10063,10064,10067],{},[23,10065,10066],{},"排程框架通常有內建","：像 Hangfire 這類排程框架本身就有分散式鎖機制，用框架的功能就好，不用自己再包一層",[28,10069,1890],{"id":1890},[11,10071,10072,10073,10076],{},"Redis 分散式鎖用 ",[15,10074,10075],{},"SET key token NX PX"," 一次完成取鎖與過期，用 Token 確認只刪自己的鎖，用 Watchdog 在工作期間續期；但程序停頓與主從切換讓它原理上無法保證絕對互斥，所以它適合用來「避免重複做事」，真正需要正確性的地方，要靠資料庫的唯一鍵、條件更新或 Fencing Token 來把關。",[1132,10078,10079],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":44,"searchDepth":386,"depth":386,"links":10081},[10082,10083,10084,10085,10086,10087,10092,10093,10094,10095],{"id":8974,"depth":386,"text":8974},{"id":9031,"depth":386,"text":9032},{"id":9080,"depth":386,"text":9081},{"id":9172,"depth":386,"text":9173},{"id":9233,"depth":386,"text":9234},{"id":9755,"depth":386,"text":9755,"children":10088},[10089,10090,10091],{"id":9765,"depth":397,"text":9765},{"id":9782,"depth":397,"text":9782},{"id":9810,"depth":397,"text":9811},{"id":9896,"depth":386,"text":9897},{"id":9987,"depth":386,"text":9988},{"id":4494,"depth":386,"text":4494},{"id":1890,"depth":386,"text":1890},"2024-11-26",{},"\u002Farticles\u002Fredis-distributed-lock",{"title":8969,"description":44},"articles\u002Fredis-distributed-lock",[10102,10103,5899,10104],"Redis","分散式鎖",".NET","bbY6h82I8TbD28qPD_6GPdLNhmA7mSx84VpQDBo8IPM",{"id":10107,"title":10108,"body":10109,"date":11061,"description":11062,"excerpt":1156,"extension":1157,"meta":11063,"navigation":1035,"path":7834,"seo":11064,"stem":11065,"tags":11066,"__hash__":11068},"articles\u002Farticles\u002Foverview-of-scrum.md","Scrum 流程整理：三個角色、三個產出物、五個事件，從 Backlog Refinement 到 Retrospective",{"type":8,"value":10110,"toc":11024},[10111,10124,10128,10134,10137,10270,10277,10281,10284,10321,10325,10329,10347,10350,10363,10366,10380,10384,10391,10409,10413,10420,10446,10453,10455,10467,10470,10473,10476,10482,10486,10497,10501,10519,10523,10526,10548,10555,10558,10560,10563,10574,10577,10580,10589,10592,10595,10615,10618,10621,10626,10629,10632,10670,10674,10694,10701,10704,10707,10726,10733,10736,10739,10745,10749,10762,10766,10784,10791,10794,10798,10824,10828,10852,10863,10867,10902,10909,10913,10942,10949,10953,10978,10981,11019,11021],[11,10112,10113,10114,10117,10118,10123],{},"換到新團隊後，開發流程改成用 Scrum 運作。剛開始參加會議時，很容易只記得「每天早上要站會」、「每兩週要 Demo 一次」，但說不清楚",[23,10115,10116],{},"每個會議為什麼存在、結束時應該產出什麼","。這篇照著一個 Sprint 的順序把流程整理一遍，順便對照 ",[119,10119,10122],{"href":10120,"rel":10121},"https:\u002F\u002Fscrumguides.org\u002Fscrum-guide.html",[3057],"Scrum Guide 2020","，修正一些網路上常見、但其實是舊版或誤解的說法。",[28,10125,10127],{"id":10126},"全貌3-個角色3-個產出物5-個事件","全貌：3 個角色、3 個產出物、5 個事件",[11,10129,10130],{},[1952,10131],{"alt":10132,"src":10133},"Scrum 一個 Sprint 的循環：左邊是由 Product Owner 負責排序的 Product Backlog，上方是長期的 Product Goal，項目透過持續進行的 Backlog Refinement 拆分與估算，越上面越清楚；Sprint Planning 從 Product Backlog 挑出項目，產生包含 Sprint Goal 與執行計畫的 Sprint Backlog；接著進入固定 1 到 4 週的 Sprint，期間每天有 Daily Scrum；Sprint 產出符合 Definition of Done 的 Increment，在 Sprint Review 中展示並收集反饋，反饋回頭調整 Product Backlog；最後的 Sprint Retrospective 檢討團隊的工作方式，改進行動帶進下一個 Sprint Planning","\u002Fimages\u002Farticles\u002Fscrum-framework.svg",[11,10135,10136],{},"Scrum 的東西不多，記住下面這張表就有完整的骨架：",[132,10138,10139,10152],{},[135,10140,10141],{},[138,10142,10143,10146,10149],{},[141,10144,10145],{},"類別",[141,10147,10148],{},"項目",[141,10150,10151],{},"一句話說明",[152,10153,10154,10168,10180,10189,10200,10210,10220,10231,10241,10251,10260],{},[138,10155,10156,10158,10161],{},[157,10157,147],{},[157,10159,10160],{},"Product Owner（PO）",[157,10162,10163,10164,10167],{},"決定",[23,10165,10166],{},"做什麼、先做哪個","，對產品價值負責",[138,10169,10170,10172,10174],{},[157,10171,147],{},[157,10173,8452],{},[157,10175,10163,10176,10179],{},[23,10177,10178],{},"怎麼做","，每個 Sprint 交付可用的 Increment",[138,10181,10182,10184,10186],{},[157,10183,147],{},[157,10185,8840],{},[157,10187,10188],{},"讓 Scrum 真的運作起來，排除阻礙、引導會議",[138,10190,10191,10194,10197],{},[157,10192,10193],{},"產出物",[157,10195,10196],{},"Product Backlog",[157,10198,10199],{},"產品所有待辦事項，排好優先順序",[138,10201,10202,10204,10207],{},[157,10203,10193],{},[157,10205,10206],{},"Sprint Backlog",[157,10208,10209],{},"這個 Sprint 的目標、選定的項目和執行計畫",[138,10211,10212,10214,10217],{},[157,10213,10193],{},[157,10215,10216],{},"Increment",[157,10218,10219],{},"這個 Sprint 完成、可以使用的產品增量",[138,10221,10222,10225,10228],{},[157,10223,10224],{},"事件",[157,10226,10227],{},"Sprint",[157,10229,10230],{},"固定長度的開發週期，其他事件都在它裡面",[138,10232,10233,10235,10238],{},[157,10234,10224],{},[157,10236,10237],{},"Sprint Planning",[157,10239,10240],{},"決定這個 Sprint 為什麼做、做什麼、怎麼做",[138,10242,10243,10245,10248],{},[157,10244,10224],{},[157,10246,10247],{},"Daily Scrum",[157,10249,10250],{},"每天 15 分鐘，檢查離 Sprint Goal 還有多遠",[138,10252,10253,10255,10257],{},[157,10254,10224],{},[157,10256,8460],{},[157,10258,10259],{},"檢視成果、收集反饋、調整 Product Backlog",[138,10261,10262,10264,10267],{},[157,10263,10224],{},[157,10265,10266],{},"Sprint Retrospective",[157,10268,10269],{},"檢視團隊的工作方式，找出要改進的地方",[11,10271,10272,10273,10276],{},"Product Backlog Refinement 不在五個事件裡。Scrum Guide 把它定義成一個",[23,10274,10275],{},"持續進行的活動","，很多團隊會固定排一個會議來做，但它不是必須的事件。",[100,10278,10280],{"id":10279},"_2020-版的幾個改動","2020 版的幾個改動",[11,10282,10283],{},"網路上很多文章還是 2017 版以前的寫法，對照時要注意：",[66,10285,10286,10292,10309,10315],{},[69,10287,10288,10291],{},[23,10289,10290],{},"「開發團隊」改成「Developers」","：以前的說法是 Scrum Team 裡面還有一個「Development Team」，2020 版拿掉了這層，Scrum Team 就是一個團隊，裡面有 PO、Scrum Master 和 Developers，避免 PO 和開發人員變成「兩邊」",[69,10293,10294,10297,10298,10300,10301,10304,10305,10308],{},[23,10295,10296],{},"每個產出物都有對應的承諾","：Product Backlog 對應 ",[23,10299,8520],{},"、Sprint Backlog 對應 ",[23,10302,10303],{},"Sprint Goal","、Increment 對應 ",[23,10306,10307],{},"Definition of Done","。產出物說明「有什麼」，承諾說明「要達到什麼」",[69,10310,10311,10314],{},[23,10312,10313],{},"Sprint Planning 從兩個主題變成三個","：多了「Why」，也就是先說清楚這個 Sprint 為什麼有價值",[69,10316,10317,10320],{},[23,10318,10319],{},"Daily Scrum 不再規定三個問題","：「昨天做了什麼、今天做什麼、有沒有阻礙」變成可選的形式，重點改成檢查離 Sprint Goal 還有多遠",[28,10322,10324],{"id":10323},"角色誰負責什麼","角色：誰負責什麼",[100,10326,10328],{"id":10327},"product-owner","Product Owner",[66,10330,10331,10337,10340],{},[69,10332,10333,10334],{},"負責管理 Product Backlog：根據市場需求、客戶反饋、商業目標，不斷新增、修改 User Story，並",[23,10335,10336],{},"排列優先順序",[69,10338,10339],{},"和 Stakeholder（利益相關者）溝通，理解需求，再把需求轉成 Product Backlog 的項目",[69,10341,10342,10343,10346],{},"PO 是",[23,10344,10345],{},"一個人","，不是一個委員會。其他人可以影響 PO 的決定，但要改變優先順序，得去說服 PO，而不是直接找開發人員插單",[100,10348,8452],{"id":10349},"developers",[66,10351,10352,10355,10360],{},[69,10353,10354],{},"負責把 Product Backlog 的項目做成可用的 Increment，包含設計、開發、測試",[69,10356,10163,10357,10359],{},[23,10358,10178],{},"：技術方案、任務拆分、誰做哪一塊，都由 Developers 自己決定",[69,10361,10362],{},"對品質負責：每個 Sprint 的成果都要符合 Definition of Done",[100,10364,8840],{"id":10365},"scrum-master",[66,10367,10368,10374,10377],{},[69,10369,10370,10371],{},"不是專案經理，也不是 Developers 的主管，",[23,10372,10373],{},"不分派工作",[69,10375,10376],{},"負責讓團隊理解並實踐 Scrum：引導會議、確保時間盒被遵守、排除團隊自己解決不了的阻礙",[69,10378,10379],{},"也要協助 PO，例如整理 Product Backlog 的方法、把 Product Goal 講清楚",[28,10381,10383],{"id":10382},"product-backlog-refinement","Product Backlog Refinement",[11,10385,10386,10387,10390],{},"Refinement 的目的，是讓 Product Backlog 上方、接下來幾個 Sprint 可能會做的項目，",[23,10388,10389],{},"在 Sprint Planning 之前就已經夠清楚","，Planning 時才不會把時間花在釐清需求。",[66,10392,10393,10396,10399,10402],{},[69,10394,10395],{},"PO 平常和 Stakeholder 討論需求，把需求轉成 User Story 和其他 Product Backlog 項目",[69,10397,10398],{},"開發團隊和 PO 定期一起討論 Product Backlog 中的項目，通常在每個 Sprint 內進行",[69,10400,10401],{},"開發人員會提出技術上的考量和建議，PO 則會澄清和修訂每個 User Story，確保它們準備好了",[69,10403,10404,10405,10408],{},"這個過程也包含對 User Story 做初步的",[23,10406,10407],{},"規模估算","（例如使用故事點），讓 PO 更好排序：同樣價值的兩個項目，成本低的通常先做",[100,10410,10412],{"id":10411},"一個項目準備好了是什麼意思","一個項目「準備好了」是什麼意思",[11,10414,10415,10416,10419],{},"很多團隊會定一個 ",[23,10417,10418],{},"Definition of Ready","（Scrum Guide 沒有這個名詞，是業界常見的做法），常見的條件：",[66,10421,10422,10428,10434,10440],{},[69,10423,10424,10427],{},[23,10425,10426],{},"描述清楚","：用「作為＿＿，我希望＿＿，以便＿＿」的格式寫出誰要用、要做什麼、為什麼",[69,10429,10430,10433],{},[23,10431,10432],{},"有驗收條件","：例如「訂單金額為 0 時不能送出」、「庫存不足時顯示提示」，Sprint Review 時才有依據判斷做完沒有",[69,10435,10436,10439],{},[23,10437,10438],{},"大小適中","：一個 Sprint 內做得完。太大的就拆，例如把「訂單管理」拆成「建立訂單」、「查詢訂單」、「取消訂單」",[69,10441,10442,10445],{},[23,10443,10444],{},"已估算","：Developers 對大小有共識",[11,10447,10448,10449,10452],{},"拆 User Story 時，要盡量",[23,10450,10451],{},"依功能切，不要依技術層切","。「建立訂單的前端」、「建立訂單的 API」、「建立訂單的資料表」分開成三個 Story，任何一個單獨完成都沒有辦法給使用者用；「建立訂單（只支援信用卡）」和「建立訂單支援貨到付款」才是各自都有價值的切法。",[100,10454,8394],{"id":8394},[66,10456,10457,10459,10461,10464],{},[69,10458,10160],{},[69,10460,8452],{},[69,10462,10463],{},"Scrum Master（可選，通常只在團隊需要引導時參加）",[69,10465,10466],{},"利益相關者或領域專家（可選，需要釐清需求時邀請）",[11,10468,10469],{},"2017 版 Scrum Guide 曾建議 Refinement 通常不超過 Developers 產能的 10%，2020 版拿掉了這個數字，但可以作為參考：Refinement 太少，Planning 會變成需求討論會；太多，會擠壓開發時間。",[28,10471,10237],{"id":10472},"sprint-planning",[11,10474,10475],{},"在每次 Sprint 開始時，會舉行 Sprint Planning 會議，整個 Scrum Team 都要參加。2020 版把它分成三個主題：",[11,10477,10478],{},[1952,10479],{"alt":10480,"src":10481},"Sprint Planning 的三個主題：第一是 Why，PO 提出這個 Sprint 為什麼有價值，全隊討論後確定 Sprint Goal，例如讓用戶能夠成功建立訂單並保存到系統中；第二是 What，PO 依優先順序提出項目，Developers 依過去的產能預估能做多少，例如建立訂單 5 點、訂單成立通知 3 點，訂單查詢放不下就留在 Product Backlog；第三是 How，由 Developers 把每個項目拆成任務，例如設計前端畫面、實作 API、寫入資料庫、撰寫單元測試，PO 在場回答問題。三者合起來就是 Sprint Backlog","\u002Fimages\u002Farticles\u002Fscrum-planning-topics.svg",[100,10483,10485],{"id":10484},"why這個-sprint-為什麼有價值","① Why：這個 Sprint 為什麼有價值",[66,10487,10488,10491],{},[69,10489,10490],{},"PO 向團隊說明，如果這個 Sprint 完成，產品會多出什麼價值，比如「這次 Sprint 我們要讓用戶能建立訂單」，並說明背景和重要性",[69,10492,10493,10494,10496],{},"全隊討論後確定 ",[23,10495,10303],{},"，Sprint Goal 必須在 Sprint Planning 結束前定下來",[100,10498,10500],{"id":10499},"what這個-sprint-做哪些項目","② What：這個 Sprint 做哪些項目",[66,10502,10503,10506,10509,10512],{},[69,10504,10505],{},"PO 依優先順序提出項目，Developers 評估能不能在這個 Sprint 內完成",[69,10507,10508],{},"Developers 依實際的開發能力對每個 User Story 估算，並參考過去幾個 Sprint 實際完成的量（Velocity），決定可以拉進 Sprint 的範圍",[69,10510,10511],{},"某些功能太複雜時，可以當場建議切分或調整範圍",[69,10513,10514,10515,10518],{},"這是",[23,10516,10517],{},"預估（Forecast）","，不是保證。2020 版刻意不說「承諾完成這些項目」，團隊承諾的是 Sprint Goal",[100,10520,10522],{"id":10521},"how要怎麼完成","③ How：要怎麼完成",[11,10524,10525],{},"開發團隊將選定的 User Story 拆成具體的 Task，並討論如何分工。Task 通常會具體到一個人一天左右可以完成，而且可以互相並行。例如：",[66,10527,10528,10531],{},[69,10529,10530],{},"User Story：「作為一個用戶，我希望能夠建立訂單，以便購買商品。」",[69,10532,10533,10534],{},"Task：\n",[211,10535,10536,10539,10542,10545],{},[69,10537,10538],{},"設計建立訂單的前端畫面",[69,10540,10541],{},"實作後端 API，處理建立訂單的請求",[69,10543,10544],{},"將訂單寫入資料庫",[69,10546,10547],{},"撰寫單元測試，確保訂單功能正確運作",[11,10549,10550,10551,10554],{},"這個步驟由 Developers 主導，",[23,10552,10553],{},"PO 不決定怎麼做","，但要留在現場：拆任務時常常會冒出「這個情況要怎麼處理？」的問題，這時候需要 PO 當場回答。很多文章說「PO 在這個階段不發言」，比較精確的說法是 PO 不主導技術決策，但要隨時能回答需求問題。",[11,10556,10557],{},"另外，Developers 不一定要在 Planning 就把整個 Sprint 的 Task 拆完。常見的做法是先把前幾天要做的拆細，後面的在 Sprint 中再細化。",[100,10559,8397],{"id":8397},[11,10561,10562],{},"這個過程不需要額外的會議，它就是 Sprint Planning 會議的一部分。Sprint Planning 結束時，應該已經完成：",[66,10564,10565,10568,10571],{},[69,10566,10567],{},"定義好的 Sprint Goal",[69,10569,10570],{},"明確的 Sprint Backlog（選擇的 User Story）",[69,10572,10573],{},"至少前幾天要做的 Task，以及團隊對分工和做法的共識",[11,10575,10576],{},"時間盒：一個月的 Sprint 最多 8 小時，Sprint 較短時通常等比例縮短，例如兩週的 Sprint 約 4 小時。",[100,10578,8394],{"id":10579},"參與者-1",[66,10581,10582,10584,10586],{},[69,10583,10160],{},[69,10585,8452],{},[69,10587,10588],{},"Scrum Master（協助引導會議）",[28,10590,10303],{"id":10591},"sprint-goal",[11,10593,10594],{},"Sprint Goal 是一句簡短而清楚的描述，概括這個 Sprint 要實現的主要成果。它應該回答「這個 Sprint 結束時，團隊要交付什麼價值」，而不只是列出任務或功能細節。",[211,10596,10597,10603,10609],{},[69,10598,10599,10602],{},[23,10600,10601],{},"簡潔明了","：用一兩句話概括，而不是一份工作清單",[69,10604,10605,10608],{},[23,10606,10607],{},"聚焦於價值","：表達這次 Sprint 要交付的業務價值，而不是技術細節",[69,10610,10611,10614],{},[23,10612,10613],{},"可衡量","：Sprint 結束時，團隊能清楚判斷目標有沒有達成",[100,10616,10617],{"id":10617},"例子",[11,10619,10620],{},"以電商的訂單管理系統為例，假設這次 Sprint 的主要工作是「作為一個用戶，我希望能夠建立訂單」，Sprint Goal 可以寫成：",[7381,10622,10623],{},[11,10624,10625],{},"讓用戶能夠成功建立訂單並保存到系統中，開始購物流程。",[11,10627,10628],{},"這個 Sprint Goal 沒有描述技術細節（例如設計 API 或連接資料庫），而是強調要交付的業務價值。",[11,10630,10631],{},"對照幾個不好的寫法：",[132,10633,10634,10644],{},[135,10635,10636],{},[138,10637,10638,10641],{},[141,10639,10640],{},"寫法",[141,10642,10643],{},"問題",[152,10645,10646,10654,10662],{},[138,10647,10648,10651],{},[157,10649,10650],{},"完成 Story #101、#102、#105",[157,10652,10653],{},"只是把 Sprint Backlog 再抄一次，看不出為什麼要做",[138,10655,10656,10659],{},[157,10657,10658],{},"實作訂單 API 與資料表",[157,10660,10661],{},"只描述技術工作，做完了用戶還是不能下單",[138,10663,10664,10667],{},[157,10665,10666],{},"讓訂單系統更好用",[157,10668,10669],{},"太模糊，Sprint 結束時無法判斷有沒有達成",[100,10671,10673],{"id":10672},"sprint-goal-的作用","Sprint Goal 的作用",[211,10675,10676,10682,10688],{},[69,10677,10678,10681],{},[23,10679,10680],{},"集中團隊精力","：讓團隊知道這個 Sprint 最重要的是什麼，避免每個人各做各的 Story",[69,10683,10684,10687],{},[23,10685,10686],{},"衡量進展","：Daily Scrum 時檢查是否朝 Sprint Goal 前進，Sprint 結束時評估是否達成",[69,10689,10690,10693],{},[23,10691,10692],{},"保留彈性","：開發中發現某個 Story 比預期複雜時，Sprint Goal 是判斷依據。只要不影響 Sprint Goal，Developers 可以和 PO 協商，把部分範圍移出這個 Sprint；反過來，如果拿掉某個 Story 會讓 Sprint Goal 無法達成，就要優先處理它",[11,10695,10696,10697,10700],{},"第 3 點是 Sprint Goal 最實際的用處：",[23,10698,10699],{},"沒有 Sprint Goal，所有 Story 看起來都一樣重要","，遇到變數時只能每個都做一點，最後每個都沒做完。",[28,10702,10206],{"id":10703},"sprint-backlog",[11,10705,10706],{},"Sprint Planning 會議後產出的 Sprint Backlog 包含三個部分：",[66,10708,10709,10714,10720],{},[69,10710,10711,10713],{},[23,10712,10303],{},"（Why）",[69,10715,10716,10719],{},[23,10717,10718],{},"從 Product Backlog 選出來的項目","（What）",[69,10721,10722,10725],{},[23,10723,10724],{},"完成這些項目的執行計畫","，也就是拆出來的 Task（How）",[11,10727,10728,10729,10732],{},"Sprint Backlog 是 ",[23,10730,10731],{},"Developers 的","，而且在整個 Sprint 中會不斷更新：做的過程中發現漏掉的 Task 就加上去、Task 完成就移到完成欄。團隊通常會用看板（待辦、進行中、完成）或燃盡圖讓進度一目了然，Daily Scrum 時也是看著它討論。",[28,10734,10227],{"id":10735},"sprint",[11,10737,10738],{},"Sprint 是 Scrum 框架的核心，它是一個固定的開發週期（1 到 4 週），在固定的時間內完成一組選定的 User Story，交付可用的產品增量。Sprint Planning、Daily Scrum、Sprint Review、Sprint Retrospective 都發生在 Sprint 裡面，一個 Sprint 結束後，下一個立刻開始。",[11,10740,10741],{},[1952,10742],{"alt":10743,"src":10744},"兩週 Sprint 的一種排法，共 10 個工作天：第 1 天上午是 Sprint Planning，約半天；第 2 天到第 10 天每天有 15 分鐘的 Daily Scrum；第 1 天下午到第 10 天上午是開發、測試與整合，Sprint Backlog 每天更新；第 7 天排一次 Backlog Refinement，整理下個 Sprint 的候選項目；第 10 天下午依序是 Sprint Review 與 Sprint Retrospective。底部列出 Scrum Guide 以一個月 Sprint 為上限的時間盒：Planning 8 小時、Daily Scrum 15 分鐘、Review 4 小時、Retrospective 3 小時，Sprint 較短時通常等比例縮短","\u002Fimages\u002Farticles\u002Fscrum-sprint-timeline.svg",[100,10746,10748],{"id":10747},"_1-sprint-開始","1. Sprint 開始",[66,10750,10751,10757],{},[69,10752,10753,10756],{},[23,10754,10755],{},"開始點","：Sprint Planning 是 Sprint 的第一個事件。Planning 結束時，團隊已經確定要完成的 User Story，並拆出了 Task",[69,10758,10759,10761],{},[23,10760,7913],{},"：達成 Sprint Goal，並盡可能完成 Sprint Backlog 中的工作",[100,10763,10765],{"id":10764},"_2-每日站會daily-scrum","2. 每日站會（Daily Scrum）",[66,10767,10768,10774,10779],{},[69,10769,10770,10773],{},[23,10771,10772],{},"頻率","：每天一次，固定 15 分鐘，在同一個時間、同一個地點，減少協調成本",[69,10775,10776,10778],{},[23,10777,8394],{},"：Developers。Scrum Master 負責確保會議有舉行、不超時，但不一定要出席；PO 如果也在做 Sprint Backlog 上的項目，就以 Developer 的身分參加",[69,10780,10781,10783],{},[23,10782,9906],{},"：檢查離 Sprint Goal 還有多遠，必要時調整接下來一天的計畫",[11,10785,10786,10787,10790],{},"最常見的形式是每個人輪流說「昨天完成了什麼、今天要做什麼、有沒有遇到阻礙」，但 2020 版已經不規定這個格式。輪流報告最常見的問題是",[23,10788,10789],{},"變成向主管報告進度","，每個人講自己的事，沒有人在看整體。比較好的做法是看著 Sprint Backlog 的看板，從最接近完成的項目開始討論：「這張卡還差什麼？誰可以幫忙？」",[11,10792,10793],{},"站會不是解決問題的會議。發現問題時，記下來，站會結束後由相關的人留下來討論，其他人回去工作。",[100,10795,10797],{"id":10796},"_3-sprint-進行中的工作","3. Sprint 進行中的工作",[66,10799,10800,10806,10812,10818],{},[69,10801,10802,10805],{},[23,10803,10804],{},"持續開發","：Developers 專注於 Sprint Backlog 中的 Task，並把功能整合到產品中",[69,10807,10808,10811],{},[23,10809,10810],{},"工作方式","：根據 Daily Scrum 的調整推進 Task，處理 Bug、撰寫測試，並和其他成員協作完成所有 Story",[69,10813,10814,10817],{},[23,10815,10816],{},"自我管理","：團隊自主決策和分工，Scrum Master 在團隊遇到自己解決不了的阻礙時協助排除",[69,10819,10820,10823],{},[23,10821,10822],{},"持續 Refinement","：Sprint 中段通常會安排一次 Refinement，為下一個 Sprint 準備好候選項目",[100,10825,10827],{"id":10826},"_4-產出可用的產品增量increment","4. 產出可用的產品增量（Increment）",[66,10829,10830,10840,10846],{},[69,10831,10832,10835,10836,10839],{},[23,10833,10834],{},"完成的定義","：每個 Sprint 都要產出至少一個可用的 Increment，而且必須符合 ",[23,10837,10838],{},"Definition of Done（DoD）","。沒有符合 DoD 的工作不算 Increment，也不能在 Sprint Review 中展示成「完成」",[69,10841,10842,10845],{},[23,10843,10844],{},"DoD 的例子","：程式碼經過 Code Review、單元測試通過、部署到測試環境、PO 驗收過驗收條件。DoD 是整個團隊的品質標準，每個項目都一樣適用",[69,10847,10848,10851],{},[23,10849,10850],{},"不一定要等到 Sprint 結束","：Increment 可以在 Sprint 中隨時交付、甚至直接上線，Sprint Review 不是唯一的發布時機",[11,10853,10854,10855,10858,10859,10862],{},"DoD 和前面的驗收條件不一樣：驗收條件是",[23,10856,10857],{},"單一 Story"," 的功能要求，DoD 是",[23,10860,10861],{},"所有項目","都要達到的品質門檻。",[100,10864,10866],{"id":10865},"_5-sprint-review衝刺檢視會議","5. Sprint Review（衝刺檢視會議）",[66,10868,10869,10875,10880,10897],{},[69,10870,10871,10874],{},[23,10872,10873],{},"時間點","：在 Sprint 的最後，一個月的 Sprint 最多 4 小時，兩週的 Sprint 大約 2 小時",[69,10876,10877,10879],{},[23,10878,8394],{},"：Scrum Team 和 PO 邀請的利益相關者",[69,10881,10882,10885,10886],{},[23,10883,10884],{},"內容","：\n",[66,10887,10888,10891,10894],{},[69,10889,10890],{},"團隊展示完成的 Increment，說明實際的功能",[69,10892,10893],{},"一起討論這段時間環境有什麼變化（市場、使用者回饋、預算）",[69,10895,10896],{},"PO 和利益相關者給出反饋，根據反饋調整 Product Backlog",[69,10898,10899,10901],{},[23,10900,9906],{},"：檢視成果、收集反饋，決定接下來最值得做的事",[11,10903,10904,10905,10908],{},"Sprint Review 是",[23,10906,10907],{},"工作會議","，不是單向的 Demo。如果每次都是團隊展示、台下說「很好」就散會，Product Backlog 沒有任何調整，這個會就沒有發揮作用。",[100,10910,10912],{"id":10911},"_6-sprint-retrospective衝刺回顧會議","6. Sprint Retrospective（衝刺回顧會議）",[66,10914,10915,10920,10925,10937],{},[69,10916,10917,10919],{},[23,10918,10873],{},"：Sprint Review 之後，是 Sprint 的最後一個事件。一個月的 Sprint 最多 3 小時，兩週的 Sprint 大約 1.5 小時",[69,10921,10922,10924],{},[23,10923,8394],{},"：整個 Scrum Team，包含 PO、Scrum Master 和 Developers",[69,10926,10927,10885,10929],{},[23,10928,10884],{},[66,10930,10931,10934],{},[69,10932,10933],{},"討論 Sprint 中做得好的地方、遇到的問題，涵蓋人、互動、流程、工具和 Definition of Done",[69,10935,10936],{},"提出具體的改進行動，最重要的那一兩項可以直接放進下一個 Sprint 的 Sprint Backlog",[69,10938,10939,10941],{},[23,10940,9906],{},"：持續改進團隊的工作方式，提高效率和品質",[11,10943,10944,10945,10948],{},"Retrospective 最常見的失敗是「每次都講一樣的問題，但什麼都沒改」。改進行動要",[23,10946,10947],{},"具體、有負責人、下次 Retrospective 檢查","，例如「Code Review 平均要等兩天」對應的行動是「每天下午 2 點固定看一次 PR，由輪值的人提醒」，而不是「大家要多注意 Code Review」。",[100,10950,10952],{"id":10951},"sprint-的核心特點","Sprint 的核心特點",[66,10954,10955,10961,10967,10972],{},[69,10956,10957,10960],{},[23,10958,10959],{},"時間盒（Time-boxed）","：每個 Sprint 都是固定長度，即使有未完成的工作也不會延長 Sprint。沒做完的項目回到 Product Backlog，由 PO 重新排序",[69,10962,10963,10966],{},[23,10964,10965],{},"Sprint Goal 不變，範圍可以調整","：Sprint 期間不能做出危害 Sprint Goal 的變更，品質標準也不能降低；但在不影響 Sprint Goal 的前提下，Developers 可以和 PO 協商、澄清或調整範圍。常見的說法「Sprint Backlog 完全不能改」並不精確，Developers 每天都在更新它",[69,10968,10969,10971],{},[23,10970,10816],{},"：Developers 在 Sprint 期間自己決定怎麼做，Scrum Master 協助排除障礙，PO 不干預具體實作",[69,10973,10974,10977],{},[23,10975,10976],{},"取消 Sprint","：只有在 Sprint Goal 已經失去意義時（例如業務方向改變），PO 可以取消 Sprint，這種情況很少見",[28,10979,10980],{"id":10980},"實務上常見的誤解",[66,10982,10983,10989,10995,11001,11007,11013],{},[69,10984,10985,10988],{},[23,10986,10987],{},"把 Scrum Master 當專案經理","：由 Scrum Master 分派任務、追進度、對外承諾時程，團隊就不會自我管理。分工由 Developers 自己決定",[69,10990,10991,10994],{},[23,10992,10993],{},"Daily Scrum 變成進度報告","：每個人對著 Scrum Master 或主管講自己的進度，沒有人關心 Sprint Goal。要對著看板、對著彼此說",[69,10996,10997,11000],{},[23,10998,10999],{},"用故事點比較團隊或個人","：故事點是團隊內部用來預估的相對大小，不同團隊的 1 點不一樣，拿來考核只會讓估算膨脹",[69,11002,11003,11006],{},[23,11004,11005],{},"Sprint 中途插單","：緊急的線上問題當然要處理，但如果每個 Sprint 都有大量插單，代表 Sprint 太長，或是需要預留處理臨時事項的產能，應該在 Retrospective 中討論，而不是默默加班",[69,11008,11009,11012],{},[23,11010,11011],{},"沒有 Definition of Done","：每個人對「完成」的認知不同，有人寫完程式就算完成，有人要上線才算，Sprint Review 時才發現一堆項目其實沒做完",[69,11014,11015,11018],{},[23,11016,11017],{},"Refinement 不足","：項目沒有提前釐清，Sprint Planning 就會拖很久，或是 Sprint 開始後才發現需求不清楚",[28,11020,1890],{"id":1890},[11,11022,11023],{},"Scrum 用固定長度的 Sprint 把開發切成一段一段：PO 透過 Refinement 讓 Product Backlog 隨時準備好，Sprint Planning 決定為什麼做（Sprint Goal）、做什麼、怎麼做，Daily Scrum 每天檢查離目標多遠，Sprint Review 檢視產品、調整 Backlog，Retrospective 檢視團隊、調整做法；每個事件都是一次「檢視與調整」，而 Sprint Goal 和 Definition of Done 是讓這些檢視有標準可依的承諾。",{"title":44,"searchDepth":386,"depth":386,"links":11025},[11026,11029,11034,11038,11045,11049,11050,11059,11060],{"id":10126,"depth":386,"text":10127,"children":11027},[11028],{"id":10279,"depth":397,"text":10280},{"id":10323,"depth":386,"text":10324,"children":11030},[11031,11032,11033],{"id":10327,"depth":397,"text":10328},{"id":10349,"depth":397,"text":8452},{"id":10365,"depth":397,"text":8840},{"id":10382,"depth":386,"text":10383,"children":11035},[11036,11037],{"id":10411,"depth":397,"text":10412},{"id":8394,"depth":397,"text":8394},{"id":10472,"depth":386,"text":10237,"children":11039},[11040,11041,11042,11043,11044],{"id":10484,"depth":397,"text":10485},{"id":10499,"depth":397,"text":10500},{"id":10521,"depth":397,"text":10522},{"id":8397,"depth":397,"text":8397},{"id":10579,"depth":397,"text":8394},{"id":10591,"depth":386,"text":10303,"children":11046},[11047,11048],{"id":10617,"depth":397,"text":10617},{"id":10672,"depth":397,"text":10673},{"id":10703,"depth":386,"text":10206},{"id":10735,"depth":386,"text":10227,"children":11051},[11052,11053,11054,11055,11056,11057,11058],{"id":10747,"depth":397,"text":10748},{"id":10764,"depth":397,"text":10765},{"id":10796,"depth":397,"text":10797},{"id":10826,"depth":397,"text":10827},{"id":10865,"depth":397,"text":10866},{"id":10911,"depth":397,"text":10912},{"id":10951,"depth":397,"text":10952},{"id":10980,"depth":386,"text":10980},{"id":1890,"depth":386,"text":1890},"2024-10-24","換到新團隊後，開發流程改成用 Scrum 運作。剛開始參加會議時，很容易只記得「每天早上要站會」、「每兩週要 Demo 一次」，但說不清楚每個會議為什麼存在、結束時應該產出什麼。這篇照著一個 Sprint 的順序把流程整理一遍，順便對照 Scrum Guide 2020，修正一些網路上常見、但其實是舊版或誤解的說法。",{},{"title":10108,"description":11062},"articles\u002Foverview-of-scrum",[8330,8328,8331,11067],"專案管理","t_s4umTDdZ-hWASJNNRCf_npK2p2OJK-VSs7tU80GpM",{"id":11070,"title":11071,"body":11072,"date":11779,"description":11780,"excerpt":1156,"extension":1157,"meta":11781,"navigation":1035,"path":8347,"seo":11782,"stem":11783,"tags":11784,"__hash__":11786},"articles\u002Farticles\u002Fevent-storming-workshop.md","Event Storming 怎麼跑？從準備、Big Picture 的七個步驟，到 Process Modeling 的便利貼語法",{"type":8,"value":11073,"toc":11753},[11074,11088,11091,11094,11153,11160,11163,11166,11173,11198,11201,11204,11236,11239,11242,11248,11356,11363,11366,11369,11389,11393,11399,11403,11406,11410,11417,11431,11434,11438,11441,11464,11471,11475,11482,11493,11497,11500,11507,11511,11518,11522,11536,11540,11547,11550,11553,11573,11577,11580,11599,11602,11616,11619,11623,11633,11636,11674,11677,11748,11750],[11,11075,11076,11078,11079,11083,11084,11087],{},[119,11077,8738],{"href":8737}," 簡單介紹過 Event Storming，",[119,11080,11082],{"href":11081},"\u002Farticles\u002Fdomain-storytelling","Domain Storytelling"," 那篇也比較過兩者的差別。這段時間實際主持了幾場，發現光知道「橘色是事件、藍色是命令」遠遠不夠，",[23,11085,11086],{},"一場 Event Storming 成不成功，八成取決於準備和主持","。這篇把流程完整整理一次。",[28,11089,11090],{"id":11090},"三種層次",[11,11092,11093],{},"Event Storming 由 Alberto Brandolini 提出，依目的不同分成三種層次：",[132,11095,11096,11110],{},[135,11097,11098],{},[138,11099,11100,11103,11105,11107],{},[141,11101,11102],{},"層次",[141,11104,9906],{},[141,11106,8394],{},[141,11108,11109],{},"時間",[152,11111,11112,11126,11140],{},[138,11113,11114,11117,11120,11123],{},[157,11115,11116],{},"Big Picture",[157,11118,11119],{},"看清楚整個業務的全貌，找出問題與邊界",[157,11121,11122],{},"各部門的領域專家、PO、開發人員",[157,11124,11125],{},"半天到一天",[138,11127,11128,11131,11134,11137],{},[157,11129,11130],{},"Process Modeling",[157,11132,11133],{},"拆解一個流程，弄清楚每一步的規則",[157,11135,11136],{},"該流程的領域專家、PO、開發人員",[157,11138,11139],{},"2～4 小時",[138,11141,11142,11145,11148,11151],{},[157,11143,11144],{},"Software Design",[157,11146,11147],{},"設計聚合、命令與事件，直接對應到程式",[157,11149,11150],{},"開發人員為主，領域專家在旁回答問題",[157,11152,11139],{},[11,11154,11155,11156,11159],{},"三者是",[23,11157,11158],{},"由寬到窄","的關係：Big Picture 找出最值得處理的流程，Process Modeling 把那個流程拆清楚，Software Design 再把它變成程式的設計。這篇主要介紹前兩種。",[28,11161,11162],{"id":11162},"準備",[100,11164,11165],{"id":11165},"找對人",[11,11167,11168,11169,11172],{},"最重要的一件事。Event Storming 的價值來自",[23,11170,11171],{},"不同部門的知識碰在一起","，所以要找：",[66,11174,11175,11181,11187,11192],{},[69,11176,11177,11180],{},[23,11178,11179],{},"真正做事的領域專家","：資深經紀人、店長、負責簽約的同事、財務。只找主管來，常常只會聽到「規定上應該怎麼做」",[69,11182,11183,11186],{},[23,11184,11185],{},"開發人員","：他們會問出業務沒想過的問題，例如「斡旋過期的那一刻，系統要做什麼？」",[69,11188,11189,11191],{},[23,11190,8834],{},"：最後要決定優先順序的人",[69,11193,11194,11197],{},[23,11195,11196],{},"主持人","：最好不是領域專家，也不是要做決定的人，才能保持中立",[11,11199,11200],{},"Big Picture 大約 8～15 人最適合。少於 5 人看不到全貌，超過 20 人很難讓每個人都參與。",[100,11202,11203],{"id":11203},"準備空間與材料",[66,11205,11206,11212,11218,11224,11230],{},[69,11207,11208,11211],{},[23,11209,11210],{},"一面很長的牆","：至少 6～8 公尺，貼上大張的海報紙或白報紙卷。牆不夠長，時間軸會被迫折行，就看不出全貌",[69,11213,11214,11217],{},[23,11215,11216],{},"把椅子移開","：大家站著才會走動、會靠過去看別人貼了什麼。坐著開會很快就變成一兩個人在講",[69,11219,11220,11223],{},[23,11221,11222],{},"大量的便利貼","：橘色要準備最多，一場 Big Picture 用掉幾百張很正常",[69,11225,11226,11229],{},[23,11227,11228],{},"粗的黑色簽字筆","：每人一支。不要用原子筆，字太小，遠一點就看不到；字寫不下也會逼大家寫得簡潔",[69,11231,11232,11235],{},[23,11233,11234],{},"圖例","：把每種顏色代表什麼，先貼在牆的一角",[11,11237,11238],{},"遠端進行可以用 Miro 這類線上白板，先把時間軸和圖例準備好。但線上很難達到實體的效果：大家容易各自安靜地貼，討論變少，主持人要更主動點名。",[28,11240,11241],{"id":11241},"便利貼的顏色與語法",[11,11243,11244],{},[1952,11245],{"alt":11246,"src":11247},"Event Storming 的便利貼圖例與 Process Modeling 語法。上方是八種便利貼：橘色是領域事件，業務上發生的事，用過去式，例如斡旋已提出；藍色是命令，使用者的意圖或決定，例如提出斡旋；小張黃色是角色，做出命令的人，例如買方；淡紫色是政策，每當某事件發生就做某事的自動反應；綠色是讀取模型，做決定前需要看的資訊；大張黃色是聚合或系統，接收命令並守住規則；粉紅色是外部系統，例如簡訊服務；桃紅色是熱點，代表疑問、衝突或痛點。下方是一條完整的語法鏈：買方看了讀取模型「物件資訊與目前斡旋狀態」後，發出「提出斡旋」命令，由斡旋聚合檢查規則，產生「斡旋已提出」事件；事件觸發政策「每當斡旋已提出，就通知屋主」，發出「通知屋主」命令，交給外部的簡訊服務；同時事件會更新讀取模型","\u002Fimages\u002Farticles\u002Fevent-storming-grammar.svg",[132,11249,11250,11262],{},[135,11251,11252],{},[138,11253,11254,11257,11260],{},[141,11255,11256],{},"顏色",[141,11258,11259],{},"元素",[141,11261,286],{},[152,11263,11264,11279,11290,11301,11312,11323,11334,11345],{},[138,11265,11266,11269,11272],{},[157,11267,11268],{},"橘色",[157,11270,11271],{},"領域事件（Domain Event）",[157,11273,11274,11275,11278],{},"業務上發生、而且有人在意的事，",[23,11276,11277],{},"用過去式","：「斡旋已提出」",[138,11280,11281,11284,11287],{},[157,11282,11283],{},"藍色",[157,11285,11286],{},"命令（Command）",[157,11288,11289],{},"使用者的意圖或決定：「提出斡旋」",[138,11291,11292,11295,11298],{},[157,11293,11294],{},"小張黃色",[157,11296,11297],{},"角色（Actor）",[157,11299,11300],{},"發出命令的人：買方、經紀人",[138,11302,11303,11306,11309],{},[157,11304,11305],{},"淡紫色",[157,11307,11308],{},"政策（Policy）",[157,11310,11311],{},"「每當…就…」的反應規則，可以是自動的，也可以是某人的固定做法",[138,11313,11314,11317,11320],{},[157,11315,11316],{},"綠色",[157,11318,11319],{},"讀取模型（Read Model）",[157,11321,11322],{},"角色做決定前需要看到的資訊",[138,11324,11325,11328,11331],{},[157,11326,11327],{},"大張黃色",[157,11329,11330],{},"聚合／系統",[157,11332,11333],{},"接收命令、檢查規則、產生事件的地方",[138,11335,11336,11339,11342],{},[157,11337,11338],{},"粉紅色",[157,11340,11341],{},"外部系統",[157,11343,11344],{},"不在我們控制範圍內的系統：簡訊服務、銀行",[138,11346,11347,11350,11353],{},[157,11348,11349],{},"桃紅色（斜貼）",[157,11351,11352],{},"熱點（Hot Spot）",[157,11354,11355],{},"疑問、衝突、痛點，任何人隨時都可以貼",[11,11357,11358,11359,11362],{},"各團隊的顏色不完全一樣，重要的是",[23,11360,11361],{},"同一場裡保持一致","，並把圖例貼在牆上。",[100,11364,11365],{"id":11365},"為什麼事件要用過去式",[11,11367,11368],{},"「斡旋已提出」和「提出斡旋」看起來只差兩個字，但意義完全不同：",[66,11370,11371,11377,11383],{},[69,11372,11373,11376],{},[23,11374,11375],{},"過去式代表已經發生的事實","，不能被拒絕、不能被撤銷，只能用另一個事件來抵銷（例如「斡旋已撤回」）",[69,11378,11379,11382],{},[23,11380,11381],{},"它逼大家講業務結果","，而不是系統動作。「資料已寫入資料庫」、「API 已呼叫」不是領域事件，業務人員看不懂，也不在意",[69,11384,11385,11388],{},[23,11386,11387],{},"事件是最容易達成共識的","：大家對「該怎麼做」可能意見不同，但對「發生了什麼」通常很快有共識",[28,11390,11392],{"id":11391},"big-picture七個步驟","Big Picture：七個步驟",[11,11394,11395],{},[1952,11396],{"alt":11397,"src":11398},"Big Picture 的牆面示意，房仲業務的事件時間軸局部。時間軸由左到右分成四段：委託與刊登有委託已簽訂、物件已拍照、物件已上架；帶看有帶看已預約、帶看已完成、帶看紀錄已填寫；斡旋有斡旋已提出、斡旋金已收取、斡旋已轉交屋主、斡旋已接受，以及另一種結果斡旋已過期；簽約交屋有買賣契約已簽訂、已交屋、服務費已收取。物件已上架、斡旋已接受、買賣契約已簽訂是關鍵事件，用垂直線把時間軸分段，各段上方標出屋主、經紀人、買方、代書等角色，以及外部系統銀行。牆面下方貼著三個熱點：物件改價後進行中的斡旋怎麼辦、多組斡旋時誰優先、斡旋已接受算不算成交。最底部列出七個步驟：混亂探索、整理時間軸、關鍵事件與邊界、人與系統、熱點、走一遍與倒著走、問題與機會投票","\u002Fimages\u002Farticles\u002Fevent-storming-big-picture.svg",[100,11400,11402],{"id":11401},"_0-開場10-分鐘","0. 開場（10 分鐘）",[11,11404,11405],{},"說明今天的目的、範圍（例如「從屋主委託到交屋」）、規則，以及圖例。範圍的起點和終點要講清楚，否則會有人從公司成立開始講。",[100,11407,11409],{"id":11408},"_1-混亂探索2030-分鐘","1. 混亂探索（20～30 分鐘）",[11,11411,11412,11413,11416],{},"每個人",[23,11414,11415],{},"各自","把自己知道的領域事件寫在橘色便利貼上，大致依時間順序貼到牆上。",[66,11418,11419,11425],{},[69,11420,11421,11424],{},[23,11422,11423],{},"不討論、不糾正","：這個階段要的是數量，重複的、寫錯的之後再處理",[69,11426,11427,11430],{},[23,11428,11429],{},"自己負責的部分就貼","：財務只知道服務費怎麼收，那就貼那一段",[11,11432,11433],{},"一開始大家會很拘謹，主持人可以自己先貼幾張示範，再點名：「店長，斡旋那一段您最熟，可以先貼嗎？」",[100,11435,11437],{"id":11436},"_2-整理時間軸3060-分鐘","2. 整理時間軸（30～60 分鐘）",[11,11439,11440],{},"大家一起把便利貼排成一條合理的時間軸：",[66,11442,11443,11452,11458],{},[69,11444,11445,11448,11449],{},[23,11446,11447],{},"合併重複的","：兩張寫法不同但意思一樣的，選一個大家都接受的說法。如果合不起來，代表其實是兩件事，或是語言有分歧，",[23,11450,11451],{},"貼一張熱點",[69,11453,11454,11457],{},[23,11455,11456],{},"補上缺漏","：時間軸上出現跳躍，問「這兩件事中間還發生了什麼？」",[69,11459,11460,11463],{},[23,11461,11462],{},"平行的流程分成泳道","：例如「帶看」和「斡旋金保管」可以同時進行，上下分開排",[11,11465,11466,11467,11470],{},"這個階段最容易吵起來，例如業務認為「斡旋已接受」就是成交，財務認為「買賣契約已簽訂」才算。",[23,11468,11469],{},"不需要當場吵出結果","，貼一張熱點，繼續往下走。",[100,11472,11474],{"id":11473},"_3-找出關鍵事件與邊界20-分鐘","3. 找出關鍵事件與邊界（20 分鐘）",[11,11476,11477,11478,11481],{},"找出幾個",[23,11479,11480],{},"改變業務階段","的事件，例如「物件已上架」、「斡旋已接受」、「買賣契約已簽訂」，在它們後面用膠帶或垂直線把時間軸分段，並在每段上方寫一個名稱。",[11,11483,11484,11485,11488,11489,11492],{},"這些分段就是子域或限界上下文的",[23,11486,11487],{},"候選","。之後可以再用 ",[119,11490,11491],{"href":8501},"子域劃分"," 那篇的方法，判斷每一段是核心、支撐還是通用。",[100,11494,11496],{"id":11495},"_4-補上人與系統20-分鐘","4. 補上人與系統（20 分鐘）",[11,11498,11499],{},"在事件旁邊貼上小張黃色的角色，以及粉紅色的外部系統：誰讓這件事發生的？依賴了哪個系統？",[11,11501,11502,11503,11506],{},"這一步常常會發現",[23,11504,11505],{},"交接的問題","，例如「斡旋金已收取」之後，到底是經紀人還是店長保管？兩個人的說法不一樣。",[100,11508,11510],{"id":11509},"_5-標出熱點全程","5. 標出熱點（全程）",[11,11512,11513,11514,11517],{},"熱點不是一個獨立的階段，而是",[23,11515,11516],{},"整場都可以貼","。任何疑問、衝突、「這裡常常出問題」都貼上去。熱點越多，代表這場越有價值：這些問題本來就存在，只是之前沒被看到。",[100,11519,11521],{"id":11520},"_6-走一遍倒著走30-分鐘","6. 走一遍、倒著走（30 分鐘）",[66,11523,11524,11530],{},[69,11525,11526,11529],{},[23,11527,11528],{},"走一遍（Explicit Walk-through）","：請一位參與者從頭講一次整條時間軸，其他人邊聽邊補充。講不下去的地方，通常就是缺了事件",[69,11531,11532,11535],{},[23,11533,11534],{},"倒著走（Reverse Narrative）","：從最後一個事件往回問「要發生這件事，前面必須先發生什麼？」。往前走容易只講順利的情況，倒著走比較容易發現前置條件，例如「服務費已收取」之前，必須先有「服務費已確認」",[100,11537,11539],{"id":11538},"_7-問題與機會投票20-分鐘","7. 問題與機會、投票（20 分鐘）",[11,11541,11542,11543,11546],{},"最後請每個人寫下他認為最大的問題或機會，再用",[23,11544,11545],{},"箭頭投票","：每人兩票，畫在他認為最該優先處理的熱點或事件上。",[11,11548,11549],{},"票數最多的地方，就是接下來要用 Process Modeling 深入的流程。",[100,11551,11552],{"id":11552},"收尾",[66,11554,11555,11561,11567],{},[69,11556,11557,11560],{},[23,11558,11559],{},"拍照","：整面牆分段拍清楚，最好有人負責整理成數位版",[69,11562,11563,11566],{},[23,11564,11565],{},"整理熱點清單","：每個熱點寫下要問誰、誰負責追",[69,11568,11569,11572],{},[23,11570,11571],{},"說明下一步","：讓參與者知道這場的結果會怎麼被使用，下次才找得到人",[28,11574,11576],{"id":11575},"process-modeling把一個流程拆清楚","Process Modeling：把一個流程拆清楚",[11,11578,11579],{},"Big Picture 投票選出「斡旋」這段後，找相關的人再跑一場 Process Modeling。這時要用上完整的語法，規則很簡單：",[211,11581,11582,11588,11594],{},[69,11583,11584,11587],{},[23,11585,11586],{},"每個事件前面都有原因","：可能是某個角色發出的命令，也可能是一條政策",[69,11589,11590,11593],{},[23,11591,11592],{},"每個命令前面都有角色或政策","，角色在發出命令前，通常需要看某個讀取模型",[69,11595,11596],{},[23,11597,11598],{},"每個事件後面接政策、讀取模型，或是流程結束",[11,11600,11601],{},"照著這個語法串下去，就會被迫回答很多 Big Picture 時跳過的問題：",[66,11603,11604,11607,11610,11613],{},[69,11605,11606],{},"買方提出斡旋時，需要看到什麼資訊？（讀取模型：物件資訊、目前是否已經有人斡旋）",[69,11608,11609],{},"「斡旋已提出」之後，誰會知道？怎麼知道？（政策：通知屋主的經紀人）",[69,11611,11612],{},"斡旋期限到了，誰來處理？（政策：每當斡旋到期，就把斡旋標記為過期、退還斡旋金）",[69,11614,11615],{},"屋主同時收到兩組斡旋時，規則是什麼？（熱點：先到先處理，還是屋主自己選？）",[11,11617,11618],{},"Process Modeling 的產出很接近需求規格：命令就是功能，事件就是功能完成的結果，政策就是要自動化或要人工處理的規則，熱點就是還沒定案的問題。",[28,11620,11622],{"id":11621},"software-design再往下一層","Software Design：再往下一層",[11,11624,11625,11626,11629,11630,11632],{},"如果團隊要開始寫程式，可以再跑一場 Software Design：把相關的命令和事件",[23,11627,11628],{},"圍繞著聚合","重新整理，問每個聚合要守住哪些規則。例如「斡旋已提出」、「斡旋已接受」、「斡旋已過期」都圍繞著「斡旋」這個聚合，「物件已上架」、「物件已改價」則屬於「物件」聚合。這部分可以參考 ",[119,11631,8738],{"href":8737}," 裡的聚合設計原則。",[28,11634,11635],{"id":11635},"主持人的技巧",[66,11637,11638,11644,11650,11656,11662,11668],{},[69,11639,11640,11643],{},[23,11641,11642],{},"控制時間，不控制內容","：每個階段都有時間盒，到了就進下一步。內容由參與者決定",[69,11645,11646,11649],{},[23,11647,11648],{},"事件要用業務語言","：看到「訂單資料已更新」，問「業務上發生了什麼事？」",[69,11651,11652,11655],{},[23,11653,11654],{},"衝突先貼熱點","：不要讓兩個人在牆前吵十分鐘，其他人站著等",[69,11657,11658,11661],{},[23,11659,11660],{},"注意沒說話的人","：資淺的人常常最清楚實際狀況，但不敢在主管面前說。主持人可以直接問他們負責的那段",[69,11663,11664,11667],{},[23,11665,11666],{},"不要讓開發人員主導","：開發人員容易把討論帶到「資料表怎麼設計」，Big Picture 階段先專注在業務",[69,11669,11670,11673],{},[23,11671,11672],{},"事前和關鍵人物溝通","：如果主管會參加，事前說明這不是報告會，是一起探索，請他多聽少講",[28,11675,11676],{"id":11676},"常見的失敗",[132,11678,11679,11691],{},[135,11680,11681],{},[138,11682,11683,11686,11689],{},[141,11684,11685],{},"狀況",[141,11687,11688],{},"原因",[141,11690,8870],{},[152,11692,11693,11704,11715,11726,11737],{},[138,11694,11695,11698,11701],{},[157,11696,11697],{},"牆上都是「點擊按鈕」、「呼叫 API」",[157,11699,11700],{},"參與者以系統操作的角度思考",[157,11702,11703],{},"開場用業務事件示範，過程中隨時提醒",[138,11705,11706,11709,11712],{},[157,11707,11708],{},"只有兩三個人在貼",[157,11710,11711],{},"坐著開會、主管在場、沒有點名",[157,11713,11714],{},"移開椅子，主持人主動邀請每個部門",[138,11716,11717,11720,11723],{},[157,11718,11719],{},"吵了一小時沒有結論",[157,11721,11722],{},"想在當下解決所有分歧",[157,11724,11725],{},"衝突貼熱點，會後再找對的人解決",[138,11727,11728,11731,11734],{},[157,11729,11730],{},"貼完就沒有下文",[157,11732,11733],{},"沒有整理產出、沒有指定負責人",[157,11735,11736],{},"收尾時整理熱點清單與下一步",[138,11738,11739,11742,11745],{},[157,11740,11741],{},"範圍太大，一天貼不完",[157,11743,11744],{},"開場沒有定義起點和終點",[157,11746,11747],{},"先跑 Big Picture，細節留給 Process Modeling",[28,11749,1890],{"id":1890},[11,11751,11752],{},"Event Storming 的關鍵在準備與主持：找齊真正做事的人、準備夠長的牆和站著的空間，Big Picture 先讓每個人各自貼出過去式的領域事件，再一起整理時間軸、找出關鍵事件與邊界、補上人與系統、隨時標出熱點，最後走一遍並投票選出最該處理的問題；選出的流程再用 Process Modeling 的「角色、命令、聚合、事件、政策、讀取模型」語法拆清楚。",{"title":44,"searchDepth":386,"depth":386,"links":11754},[11755,11756,11760,11763,11774,11775,11776,11777,11778],{"id":11090,"depth":386,"text":11090},{"id":11162,"depth":386,"text":11162,"children":11757},[11758,11759],{"id":11165,"depth":397,"text":11165},{"id":11203,"depth":397,"text":11203},{"id":11241,"depth":386,"text":11241,"children":11761},[11762],{"id":11365,"depth":397,"text":11365},{"id":11391,"depth":386,"text":11392,"children":11764},[11765,11766,11767,11768,11769,11770,11771,11772,11773],{"id":11401,"depth":397,"text":11402},{"id":11408,"depth":397,"text":11409},{"id":11436,"depth":397,"text":11437},{"id":11473,"depth":397,"text":11474},{"id":11495,"depth":397,"text":11496},{"id":11509,"depth":397,"text":11510},{"id":11520,"depth":397,"text":11521},{"id":11538,"depth":397,"text":11539},{"id":11552,"depth":397,"text":11552},{"id":11575,"depth":386,"text":11576},{"id":11621,"depth":386,"text":11622},{"id":11635,"depth":386,"text":11635},{"id":11676,"depth":386,"text":11676},{"id":1890,"depth":386,"text":1890},"2024-09-24","DDD 概論 簡單介紹過 Event Storming，Domain Storytelling 那篇也比較過兩者的差別。這段時間實際主持了幾場，發現光知道「橘色是事件、藍色是命令」遠遠不夠，一場 Event Storming 成不成功，八成取決於準備和主持。這篇把流程完整整理一次。",{},{"title":11071,"description":11780},"articles\u002Fevent-storming-workshop",[8964,11785,8963,8965,8331],"領域驅動設計","SUbue8-enCXyQs1KxPIBWKcFPL-SHNwo8RMBleGYeSI",{"id":11788,"title":11789,"body":11790,"date":12394,"description":12395,"excerpt":1156,"extension":1157,"meta":12396,"navigation":1035,"path":12397,"seo":12398,"stem":12399,"tags":12400,"__hash__":12402},"articles\u002Farticles\u002Fopentelemetry-aspnetcore-instrumentation.md","幫 ASP.NET Core 接上 OpenTelemetry：從零到 Grafana／Tempo／Loki",{"type":8,"value":11791,"toc":12387},[11792,11800,11803,11810,11841,11847,11850,11876,11880,11899,11902,12160,12167,12267,12362,12365,12378,12381,12384],[11,11793,11794,11795,114],{},"分散式架構下服務一多（Message Queue、各種微服務、Restful／gRPC／GraphQL\nAPI），要追蹤 API 異常、改善流程、確認 SLI 指標，都需要一套可觀測性架構打\n底。這篇把「如何從零幫一個 ASP.NET Core 專案接上 OpenTelemetry、並串到\nGrafana 全家桶」整理成一份可以直接照抄的參考做法，完整可執行的範例放在\n",[119,11796,11799],{"href":11797,"rel":11798},"https:\u002F\u002Fgithub.com\u002Ftom3019\u002FWeb-Application-Observability-and-Availability",[3057],"Web-Application-Observability-and-Availability",[28,11801,11802],{"id":11802},"解決方案架構",[11,11804,11805,11806,11809],{},"以 ",[23,11807,11808],{},"OpenTelemetry（OTEL）"," 作為統一的可觀測性框架，搭配 Grafana 生態系：",[66,11811,11812,11818,11823,11829,11835],{},[69,11813,11814,11817],{},[23,11815,11816],{},"OpenTelemetry Collector","：收集 Trace 與 Metric 的集線器，可輸出到\nGrafana、Jaeger 等不同後端",[69,11819,11820,11822],{},[23,11821,2756],{},"：儲存時序資料（CPU、Memory 等）的監控方案",[69,11824,11825,11828],{},[23,11826,11827],{},"Grafana","：將 Prometheus、資料庫等來源的資料視覺化",[69,11830,11831,11834],{},[23,11832,11833],{},"Grafana Tempo","：開源、可大規模擴展的分散式追蹤後端",[69,11836,11837,11840],{},[23,11838,11839],{},"Grafana Loki","：可擴展、多租戶的日誌聚合系統",[11,11842,11843],{},[1952,11844],{"alt":11845,"src":11846},"可觀測性解決方案架構","https:\u002F\u002Fraw.githubusercontent.com\u002Ftom3019\u002FWeb-Application-Observability-and-Availability\u002Fmaster\u002Fimages\u002F%E7%9B%A3%E6%8E%A7%E6%9E%B6%E6%A7%8B.png",[28,11848,11849],{"id":11849},"本機執行",[36,11851,11855],{"className":11852,"code":11853,"language":11854,"meta":44,"style":44},"language-shell shiki shiki-themes github-dark","# 先調整 configs\u002F 內各服務的 IP 與 Port\ndocker compose up -d\n","shell",[15,11856,11857,11862],{"__ignoreMap":44},[376,11858,11859],{"class":378,"line":379},[376,11860,11861],{"class":382},"# 先調整 configs\u002F 內各服務的 IP 與 Port\n",[376,11863,11864,11867,11870,11873],{"class":378,"line":386},[376,11865,11866],{"class":1020},"docker",[376,11868,11869],{"class":414}," compose",[376,11871,11872],{"class":414}," up",[376,11874,11875],{"class":560}," -d\n",[28,11877,11879],{"id":11878},"幫-aspnet-core-8-接上可觀測性","幫 ASP.NET Core 8 接上可觀測性",[11,11881,11882,11883,11886,11887,11886,11890,11886,11893,11886,11896],{},"安裝套件：",[15,11884,11885],{},"OpenTelemetry.Extensions.Hosting","、\n",[15,11888,11889],{},"OpenTelemetry.Instrumentation.AspNetCore",[15,11891,11892],{},"OpenTelemetry.Instrumentation.Http",[15,11894,11895],{},"OpenTelemetry.Exporter.OpenTelemetryProtocol",[15,11897,11898],{},"OpenTelemetry.Exporter.Prometheus.AspNetCore",[11,11900,11901],{},"Tracing 與 Metrics 的註冊：",[36,11903,11905],{"className":3145,"code":11904,"language":3147,"meta":44,"style":44},"var otel = builder.Services.AddOpenTelemetry();\notel.ConfigureResource(resource => resource\n    .AddService(serviceName: builder.Environment.ApplicationName));\n\notel.WithMetrics(metrics => metrics\n    .AddAspNetCoreInstrumentation()\n    .AddMeter(greeterMeter.Name)\n    .AddMeter(\"Microsoft.AspNetCore.Hosting\")\n    .AddPrometheusExporter());\n\notel.WithTracing(tracing =>\n{\n    tracing.AddAspNetCoreInstrumentation(o =>\n    {\n        \u002F\u002F 從 X-Forwarded-For 或連線資訊解析真實客戶端 IP，寫入 span tag\n        o.EnrichWithHttpRequest = (activity, request) => { \u002F* ... *\u002F };\n    });\n    tracing.AddHttpClientInstrumentation();\n    tracing.AddSource(TracingActivitySource.WebApiRegisteredActivity.Name);\n    tracing.AddSource(TracingActivitySource.ServiceRegisteredActivity.Name);\n    tracing.AddOtlpExporter(o => o.Endpoint = new Uri(\"http:\u002F\u002Fotel:4317\"));\n});\n",[15,11906,11907,11924,11941,11956,11960,11977,11986,11996,12009,12019,12023,12038,12042,12056,12060,12065,12094,12098,12107,12117,12126,12156],{"__ignoreMap":44},[376,11908,11909,11911,11914,11916,11919,11922],{"class":378,"line":379},[376,11910,4684],{"class":3196},[376,11912,11913],{"class":1020}," otel",[376,11915,3313],{"class":3196},[376,11917,11918],{"class":393}," builder.Services.",[376,11920,11921],{"class":1020},"AddOpenTelemetry",[376,11923,3532],{"class":393},[376,11925,11926,11929,11932,11934,11936,11938],{"class":378,"line":386},[376,11927,11928],{"class":393},"otel.",[376,11930,11931],{"class":1020},"ConfigureResource",[376,11933,3220],{"class":393},[376,11935,9323],{"class":1020},[376,11937,3246],{"class":3196},[376,11939,11940],{"class":393}," resource\n",[376,11942,11943,11945,11948,11950,11953],{"class":378,"line":397},[376,11944,3170],{"class":393},[376,11946,11947],{"class":1020},"AddService",[376,11949,3220],{"class":393},[376,11951,11952],{"class":1020},"serviceName",[376,11954,11955],{"class":393},": builder.Environment.ApplicationName));\n",[376,11957,11958],{"class":378,"line":405},[376,11959,1036],{"emptyLinePlaceholder":1035},[376,11961,11962,11964,11967,11969,11972,11974],{"class":378,"line":418},[376,11963,11928],{"class":393},[376,11965,11966],{"class":1020},"WithMetrics",[376,11968,3220],{"class":393},[376,11970,11971],{"class":1020},"metrics",[376,11973,3246],{"class":3196},[376,11975,11976],{"class":393}," metrics\n",[376,11978,11979,11981,11984],{"class":378,"line":429},[376,11980,3170],{"class":393},[376,11982,11983],{"class":1020},"AddAspNetCoreInstrumentation",[376,11985,3165],{"class":393},[376,11987,11988,11990,11993],{"class":378,"line":437},[376,11989,3170],{"class":393},[376,11991,11992],{"class":1020},"AddMeter",[376,11994,11995],{"class":393},"(greeterMeter.Name)\n",[376,11997,11998,12000,12002,12004,12007],{"class":378,"line":447},[376,11999,3170],{"class":393},[376,12001,11992],{"class":1020},[376,12003,3220],{"class":393},[376,12005,12006],{"class":414},"\"Microsoft.AspNetCore.Hosting\"",[376,12008,3319],{"class":393},[376,12010,12011,12013,12016],{"class":378,"line":531},[376,12012,3170],{"class":393},[376,12014,12015],{"class":1020},"AddPrometheusExporter",[376,12017,12018],{"class":393},"());\n",[376,12020,12021],{"class":378,"line":542},[376,12022,1036],{"emptyLinePlaceholder":1035},[376,12024,12025,12027,12030,12032,12035],{"class":378,"line":552},[376,12026,11928],{"class":393},[376,12028,12029],{"class":1020},"WithTracing",[376,12031,3220],{"class":393},[376,12033,12034],{"class":1020},"tracing",[376,12036,12037],{"class":3196}," =>\n",[376,12039,12040],{"class":378,"line":564},[376,12041,3324],{"class":393},[376,12043,12044,12047,12049,12051,12054],{"class":378,"line":575},[376,12045,12046],{"class":393},"    tracing.",[376,12048,11983],{"class":1020},[376,12050,3220],{"class":393},[376,12052,12053],{"class":1020},"o",[376,12055,12037],{"class":3196},[376,12057,12058],{"class":378,"line":583},[376,12059,5704],{"class":393},[376,12061,12062],{"class":378,"line":597},[376,12063,12064],{"class":382},"        \u002F\u002F 從 X-Forwarded-For 或連線資訊解析真實客戶端 IP，寫入 span tag\n",[376,12066,12067,12070,12072,12074,12077,12079,12082,12084,12086,12088,12091],{"class":378,"line":605},[376,12068,12069],{"class":393},"        o.EnrichWithHttpRequest ",[376,12071,3240],{"class":3196},[376,12073,5046],{"class":393},[376,12075,12076],{"class":1020},"activity",[376,12078,3188],{"class":393},[376,12080,12081],{"class":1020},"request",[376,12083,5662],{"class":393},[376,12085,5665],{"class":3196},[376,12087,9621],{"class":393},[376,12089,12090],{"class":382},"\u002F* ... *\u002F",[376,12092,12093],{"class":393}," };\n",[376,12095,12096],{"class":378,"line":616},[376,12097,9554],{"class":393},[376,12099,12100,12102,12105],{"class":378,"line":624},[376,12101,12046],{"class":393},[376,12103,12104],{"class":1020},"AddHttpClientInstrumentation",[376,12106,3532],{"class":393},[376,12108,12109,12111,12114],{"class":378,"line":635},[376,12110,12046],{"class":393},[376,12112,12113],{"class":1020},"AddSource",[376,12115,12116],{"class":393},"(TracingActivitySource.WebApiRegisteredActivity.Name);\n",[376,12118,12119,12121,12123],{"class":378,"line":5815},[376,12120,12046],{"class":393},[376,12122,12113],{"class":1020},[376,12124,12125],{"class":393},"(TracingActivitySource.ServiceRegisteredActivity.Name);\n",[376,12127,12128,12130,12133,12135,12137,12139,12142,12144,12146,12149,12151,12154],{"class":378,"line":7233},[376,12129,12046],{"class":393},[376,12131,12132],{"class":1020},"AddOtlpExporter",[376,12134,3220],{"class":393},[376,12136,12053],{"class":1020},[376,12138,3246],{"class":3196},[376,12140,12141],{"class":393}," o.Endpoint ",[376,12143,3240],{"class":3196},[376,12145,4692],{"class":3196},[376,12147,12148],{"class":1020}," Uri",[376,12150,3220],{"class":393},[376,12152,12153],{"class":414},"\"http:\u002F\u002Fotel:4317\"",[376,12155,3839],{"class":393},[376,12157,12158],{"class":378,"line":7243},[376,12159,3853],{"class":393},[11,12161,12162,12163,12166],{},"分層命名 ",[15,12164,12165],{},"ActivitySource","，讓一次請求跨 WebApi／Service／Repository 三層的\nTrace 都能被串起來：",[36,12168,12170],{"className":3145,"code":12169,"language":3147,"meta":44,"style":44},"public static class TracingActivitySource\n{\n    public static readonly ActivitySource WebApiRegisteredActivity = new(\"Monitor.Sample.Api\");\n    public static readonly ActivitySource ServiceRegisteredActivity = new(\"Monitor.Sample.Service\");\n    public static readonly ActivitySource RepositoryRegisteredActivity = new(\"Monitor.Sample.Repository\");\n}\n",[15,12171,12172,12184,12188,12215,12239,12263],{"__ignoreMap":44},[376,12173,12174,12176,12178,12181],{"class":378,"line":379},[376,12175,3274],{"class":3196},[376,12177,3411],{"class":3196},[376,12179,12180],{"class":3196}," class",[376,12182,12183],{"class":1020}," TracingActivitySource\n",[376,12185,12186],{"class":378,"line":386},[376,12187,3324],{"class":393},[376,12189,12190,12193,12195,12198,12201,12204,12206,12208,12210,12213],{"class":378,"line":397},[376,12191,12192],{"class":3196},"    public",[376,12194,3411],{"class":3196},[376,12196,12197],{"class":3196}," readonly",[376,12199,12200],{"class":1020}," ActivitySource",[376,12202,12203],{"class":1020}," WebApiRegisteredActivity",[376,12205,3313],{"class":3196},[376,12207,4692],{"class":3196},[376,12209,3220],{"class":393},[376,12211,12212],{"class":414},"\"Monitor.Sample.Api\"",[376,12214,3376],{"class":393},[376,12216,12217,12219,12221,12223,12225,12228,12230,12232,12234,12237],{"class":378,"line":405},[376,12218,12192],{"class":3196},[376,12220,3411],{"class":3196},[376,12222,12197],{"class":3196},[376,12224,12200],{"class":1020},[376,12226,12227],{"class":1020}," ServiceRegisteredActivity",[376,12229,3313],{"class":3196},[376,12231,4692],{"class":3196},[376,12233,3220],{"class":393},[376,12235,12236],{"class":414},"\"Monitor.Sample.Service\"",[376,12238,3376],{"class":393},[376,12240,12241,12243,12245,12247,12249,12252,12254,12256,12258,12261],{"class":378,"line":418},[376,12242,12192],{"class":3196},[376,12244,3411],{"class":3196},[376,12246,12197],{"class":3196},[376,12248,12200],{"class":1020},[376,12250,12251],{"class":1020}," RepositoryRegisteredActivity",[376,12253,3313],{"class":3196},[376,12255,4692],{"class":3196},[376,12257,3220],{"class":393},[376,12259,12260],{"class":414},"\"Monitor.Sample.Repository\"",[376,12262,3376],{"class":393},[376,12264,12265],{"class":378,"line":429},[376,12266,3381],{"class":393},[36,12268,12270],{"className":3145,"code":12269,"language":3147,"meta":44,"style":44},"public Task\u003Cint> GetNumberAsync()\n{\n    using (TracingActivitySource.ServiceRegisteredActivity.StartActivity(\n        $\"{nameof(TracingTestService)}.{nameof(GetNumberAsync)}\"))\n    {\n        return tracingTestRepository.GetNumberAsync();\n    }\n}\n",[15,12271,12272,12289,12293,12305,12339,12343,12354,12358],{"__ignoreMap":44},[376,12273,12274,12276,12278,12280,12282,12284,12287],{"class":378,"line":379},[376,12275,3274],{"class":3196},[376,12277,3280],{"class":1020},[376,12279,3176],{"class":393},[376,12281,5089],{"class":3196},[376,12283,3288],{"class":393},[376,12285,12286],{"class":1020},"GetNumberAsync",[376,12288,3165],{"class":393},[376,12290,12291],{"class":378,"line":386},[376,12292,3324],{"class":393},[376,12294,12295,12297,12300,12303],{"class":378,"line":397},[376,12296,9419],{"class":3196},[376,12298,12299],{"class":393}," (TracingActivitySource.ServiceRegisteredActivity.",[376,12301,12302],{"class":1020},"StartActivity",[376,12304,3294],{"class":393},[376,12306,12307,12310,12312,12315,12317,12320,12323,12325,12327,12329,12331,12333,12335,12337],{"class":378,"line":405},[376,12308,12309],{"class":414},"        $\"",[376,12311,4729],{"class":414},[376,12313,12314],{"class":3196},"nameof",[376,12316,3220],{"class":414},[376,12318,12319],{"class":393},"TracingTestService",[376,12321,12322],{"class":414},")}",[376,12324,4735],{"class":414},[376,12326,4729],{"class":414},[376,12328,12314],{"class":3196},[376,12330,3220],{"class":414},[376,12332,12286],{"class":393},[376,12334,12322],{"class":414},[376,12336,4744],{"class":414},[376,12338,5025],{"class":393},[376,12340,12341],{"class":378,"line":418},[376,12342,5704],{"class":393},[376,12344,12345,12347,12350,12352],{"class":378,"line":429},[376,12346,3485],{"class":3196},[376,12348,12349],{"class":393}," tracingTestRepository.",[376,12351,12286],{"class":1020},[376,12353,3532],{"class":393},[376,12355,12356],{"class":378,"line":437},[376,12357,5766],{"class":393},[376,12359,12360],{"class":378,"line":447},[376,12361,3381],{"class":393},[28,12363,12364],{"id":12364},"踩過的坑",[7381,12366,12367],{},[11,12368,12369,12370,12372,12373,5226],{},"⚠️ ",[15,12371,11898],{}," 版本 1.8.0 會導致\nPrometheus 收集 log 錯誤，需改回 1.7.0 或更新版本才能解決\n（",[119,12374,12377],{"href":12375,"rel":12376},"https:\u002F\u002Fgithub.com\u002Fopen-telemetry\u002Fopentelemetry-dotnet\u002Fissues\u002F5506",[3057],"對應 issue",[28,12379,12380],{"id":12380},"延伸應用",[11,12382,12383],{},"這次在範例專案上把 Traces、Logs、Metrics 三條線都串起來了，下一步是套用到正式\n環境：統一告警儀表板，並依業務情境挑選合適的 Prometheus 指標類型（Counter、\nGauge、Histogram），讓告警規則與 PromQL 查詢有一致的設計基礎。",[1132,12385,12386],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}",{"title":44,"searchDepth":386,"depth":386,"links":12388},[12389,12390,12391,12392,12393],{"id":11802,"depth":386,"text":11802},{"id":11849,"depth":386,"text":11849},{"id":11878,"depth":386,"text":11879},{"id":12364,"depth":386,"text":12364},{"id":12380,"depth":386,"text":12380},"2024-08-19","分散式架構下服務一多（Message Queue、各種微服務、Restful／gRPC／GraphQL\nAPI），要追蹤 API 異常、改善流程、確認 SLI 指標，都需要一套可觀測性架構打\n底。這篇把「如何從零幫一個 ASP.NET Core 專案接上 OpenTelemetry、並串到\nGrafana 全家桶」整理成一份可以直接照抄的參考做法，完整可執行的範例放在\nWeb-Application-Observability-and-Availability。",{},"\u002Farticles\u002Fopentelemetry-aspnetcore-instrumentation",{"title":11789,"description":12395},"articles\u002Fopentelemetry-aspnetcore-instrumentation",[12401,11827,2757,3902],"OpenTelemetry","GPulDgpwrZCPTPRVgZlb_RD1UvR2aFEt83JeSIeYB4o",{"id":12404,"title":12405,"body":12406,"date":13664,"description":13665,"excerpt":1156,"extension":1157,"meta":13666,"navigation":1035,"path":13667,"seo":13668,"stem":13669,"tags":13670,"__hash__":13673},"articles\u002Farticles\u002Fredis-cache-penetration-breakdown-avalanche.md","Redis 快取怎麼用才不會出事？Cache-Aside、資料一致性，與穿透、擊穿、雪崩三個問題",{"type":8,"value":12407,"toc":13648},[12408,12419,12423,12429,12436,12441,12452,12457,12472,12754,12758,12765,12817,12824,12827,12831,12838,12849,12852,12855,12881,12888,12891,12897,12901,12919,12924,12944,12948,12959,12963,12990,12994,13001,13009,13012,13016,13050,13053,13551,13561,13565,13568,13603,13605,13640,13642,13645],[11,12409,12410,12411,12415,12416,114],{},"Redis 的資料結構與基本操作可以先參考 ",[119,12412,12414],{"href":12413},"\u002Farticles\u002Fredis-data-structures-use-cases","Redis 不只是快取","。這篇專注在最常見的用法：",[23,12417,12418],{},"把它放在資料庫前面當快取",[28,12420,12422],{"id":12421},"cache-aside最常用的快取模式","Cache-Aside：最常用的快取模式",[11,12424,12425],{},[1952,12426],{"alt":12427,"src":12428},"Cache-Aside 流程示意圖：讀取時先查 Redis，命中就直接回傳；沒命中就查資料庫，把結果寫回 Redis 並設定過期時間，再回傳。寫入時先更新資料庫，成功後刪除 Redis 中對應的 Key，下一次讀取時再從資料庫重新載入","\u002Fimages\u002Farticles\u002Fredis-cache-aside.svg",[11,12430,12431,12432,12435],{},"Cache-Aside（旁路快取）是由",[23,12433,12434],{},"應用程式自己","負責讀寫快取，Redis 和資料庫彼此不知道對方存在：",[11,12437,12438,55],{},[23,12439,12440],{},"讀取",[211,12442,12443,12446,12449],{},[69,12444,12445],{},"先查 Redis，有就直接回傳（Cache Hit）",[69,12447,12448],{},"沒有（Cache Miss）就查資料庫",[69,12450,12451],{},"把查到的結果寫進 Redis，設定過期時間，再回傳",[11,12453,12454,55],{},[23,12455,12456],{},"寫入",[211,12458,12459,12462,12469],{},[69,12460,12461],{},"先更新資料庫",[69,12463,12464,12465,12468],{},"成功後",[23,12466,12467],{},"刪除"," Redis 裡對應的 Key",[69,12470,12471],{},"下一次有人讀取時，就會因為 Cache Miss 從資料庫載入最新的資料",[36,12473,12475],{"className":3145,"code":12474,"language":3147,"meta":44,"style":44},"public async Task\u003CHouse?> GetHouseAsync(int id)\n{\n    var key = $\"house:{id}\";\n    var cached = await _redis.StringGetAsync(key);\n    if (cached.HasValue)\n        return JsonSerializer.Deserialize\u003CHouse>(cached.ToString());\n\n    var house = await _db.Houses.FindAsync(id);\n    if (house is not null)\n        await _redis.StringSetAsync(key, JsonSerializer.Serialize(house), TimeSpan.FromMinutes(10));\n\n    return house;\n}\n\npublic async Task UpdateHouseAsync(House house)\n{\n    _db.Houses.Update(house);\n    await _db.SaveChangesAsync();          \u002F\u002F 1. 先更新資料庫\n    await _redis.KeyDeleteAsync($\"house:{house.Id}\");  \u002F\u002F 2. 再刪除快取\n}\n",[15,12476,12477,12505,12509,12531,12550,12557,12578,12582,12602,12620,12647,12651,12658,12662,12666,12685,12689,12700,12716,12750],{"__ignoreMap":44},[376,12478,12479,12481,12483,12485,12487,12490,12493,12496,12498,12500,12503],{"class":378,"line":379},[376,12480,3274],{"class":3196},[376,12482,3277],{"class":3196},[376,12484,3280],{"class":1020},[376,12486,3176],{"class":393},[376,12488,12489],{"class":1020},"House",[376,12491,12492],{"class":393},"?> ",[376,12494,12495],{"class":1020},"GetHouseAsync",[376,12497,3220],{"class":393},[376,12499,5089],{"class":3196},[376,12501,12502],{"class":1020}," id",[376,12504,3319],{"class":393},[376,12506,12507],{"class":378,"line":386},[376,12508,3324],{"class":393},[376,12510,12511,12513,12515,12517,12520,12522,12525,12527,12529],{"class":378,"line":397},[376,12512,5061],{"class":3196},[376,12514,9313],{"class":1020},[376,12516,3313],{"class":3196},[376,12518,12519],{"class":414}," $\"house:",[376,12521,4729],{"class":414},[376,12523,12524],{"class":393},"id",[376,12526,4741],{"class":414},[376,12528,4744],{"class":414},[376,12530,3428],{"class":393},[376,12532,12533,12535,12538,12540,12542,12544,12547],{"class":378,"line":405},[376,12534,5061],{"class":3196},[376,12536,12537],{"class":1020}," cached",[376,12539,3313],{"class":3196},[376,12541,9704],{"class":3196},[376,12543,9393],{"class":393},[376,12545,12546],{"class":1020},"StringGetAsync",[376,12548,12549],{"class":393},"(key);\n",[376,12551,12552,12554],{"class":378,"line":418},[376,12553,3477],{"class":3196},[376,12555,12556],{"class":393}," (cached.HasValue)\n",[376,12558,12559,12561,12564,12567,12569,12571,12574,12576],{"class":378,"line":429},[376,12560,3485],{"class":3196},[376,12562,12563],{"class":393}," JsonSerializer.",[376,12565,12566],{"class":1020},"Deserialize",[376,12568,3176],{"class":393},[376,12570,12489],{"class":1020},[376,12572,12573],{"class":393},">(cached.",[376,12575,9349],{"class":1020},[376,12577,12018],{"class":393},[376,12579,12580],{"class":378,"line":437},[376,12581,1036],{"emptyLinePlaceholder":1035},[376,12583,12584,12586,12589,12591,12593,12596,12599],{"class":378,"line":447},[376,12585,5061],{"class":3196},[376,12587,12588],{"class":1020}," house",[376,12590,3313],{"class":3196},[376,12592,9704],{"class":3196},[376,12594,12595],{"class":393}," _db.Houses.",[376,12597,12598],{"class":1020},"FindAsync",[376,12600,12601],{"class":393},"(id);\n",[376,12603,12604,12606,12609,12612,12615,12618],{"class":378,"line":531},[376,12605,3477],{"class":3196},[376,12607,12608],{"class":393}," (house ",[376,12610,12611],{"class":3196},"is",[376,12613,12614],{"class":3196}," not",[376,12616,12617],{"class":560}," null",[376,12619,3319],{"class":393},[376,12621,12622,12624,12626,12629,12632,12635,12638,12641,12643,12645],{"class":378,"line":542},[376,12623,9572],{"class":3196},[376,12625,9393],{"class":393},[376,12627,12628],{"class":1020},"StringSetAsync",[376,12630,12631],{"class":393},"(key, JsonSerializer.",[376,12633,12634],{"class":1020},"Serialize",[376,12636,12637],{"class":393},"(house), TimeSpan.",[376,12639,12640],{"class":1020},"FromMinutes",[376,12642,3220],{"class":393},[376,12644,361],{"class":560},[376,12646,3839],{"class":393},[376,12648,12649],{"class":378,"line":552},[376,12650,1036],{"emptyLinePlaceholder":1035},[376,12652,12653,12655],{"class":378,"line":564},[376,12654,3362],{"class":3196},[376,12656,12657],{"class":393}," house;\n",[376,12659,12660],{"class":378,"line":575},[376,12661,3381],{"class":393},[376,12663,12664],{"class":378,"line":583},[376,12665,1036],{"emptyLinePlaceholder":1035},[376,12667,12668,12670,12672,12674,12677,12679,12681,12683],{"class":378,"line":597},[376,12669,3274],{"class":3196},[376,12671,3277],{"class":3196},[376,12673,3280],{"class":1020},[376,12675,12676],{"class":1020}," UpdateHouseAsync",[376,12678,3220],{"class":393},[376,12680,12489],{"class":1020},[376,12682,12588],{"class":1020},[376,12684,3319],{"class":393},[376,12686,12687],{"class":378,"line":605},[376,12688,3324],{"class":393},[376,12690,12691,12694,12697],{"class":378,"line":616},[376,12692,12693],{"class":393},"    _db.Houses.",[376,12695,12696],{"class":1020},"Update",[376,12698,12699],{"class":393},"(house);\n",[376,12701,12702,12704,12707,12710,12713],{"class":378,"line":624},[376,12703,3329],{"class":3196},[376,12705,12706],{"class":393}," _db.",[376,12708,12709],{"class":1020},"SaveChangesAsync",[376,12711,12712],{"class":393},"();          ",[376,12714,12715],{"class":382},"\u002F\u002F 1. 先更新資料庫\n",[376,12717,12718,12720,12722,12725,12727,12730,12732,12735,12737,12740,12742,12744,12747],{"class":378,"line":635},[376,12719,3329],{"class":3196},[376,12721,9393],{"class":393},[376,12723,12724],{"class":1020},"KeyDeleteAsync",[376,12726,3220],{"class":393},[376,12728,12729],{"class":414},"$\"house:",[376,12731,4729],{"class":414},[376,12733,12734],{"class":393},"house",[376,12736,4735],{"class":414},[376,12738,12739],{"class":393},"Id",[376,12741,4741],{"class":414},[376,12743,4744],{"class":414},[376,12745,12746],{"class":393},");  ",[376,12748,12749],{"class":382},"\u002F\u002F 2. 再刪除快取\n",[376,12751,12752],{"class":378,"line":5815},[376,12753,3381],{"class":393},[100,12755,12757],{"id":12756},"為什麼是刪除快取而不是更新快取","為什麼是「刪除」快取，而不是「更新」快取？",[11,12759,12760,12761,12764],{},"直覺上，資料庫改了，順便把新值寫進 Redis 不是更好嗎？問題出在",[23,12762,12763],{},"兩個請求同時更新","的時候：",[132,12766,12767,12779],{},[135,12768,12769],{},[138,12770,12771,12773,12776],{},[141,12772,11109],{},[141,12774,12775],{},"請求 A（改價為 1000 萬）",[141,12777,12778],{},"請求 B（改價為 1200 萬）",[152,12780,12781,12790,12799,12808],{},[138,12782,12783,12785,12788],{},[157,12784,892],{},[157,12786,12787],{},"更新資料庫 → 1000 萬",[157,12789],{},[138,12791,12792,12794,12796],{},[157,12793,347],{},[157,12795],{},[157,12797,12798],{},"更新資料庫 → 1200 萬",[138,12800,12801,12803,12805],{},[157,12802,6235],{},[157,12804],{},[157,12806,12807],{},"更新快取 → 1200 萬",[138,12809,12810,12812,12815],{},[157,12811,713],{},[157,12813,12814],{},"更新快取 → 1000 萬",[157,12816],{},[11,12818,12819,12820,12823],{},"資料庫最後是 1200 萬，快取卻是 1000 萬，",[23,12821,12822],{},"而且會一直錯到過期為止","。網路延遲、GC 停頓都可能讓 A 的第二步比 B 慢。",[11,12825,12826],{},"改成刪除，不管誰先誰後，結果都是「快取裡沒有這個 Key」，下一次讀取一定從資料庫拿最新值。另外，很多資料改了之後不一定馬上有人讀，每次更新都重新寫快取也是浪費。",[100,12828,12830],{"id":12829},"為什麼是先更新資料庫再刪快取","為什麼是「先更新資料庫，再刪快取」？",[11,12832,12833,12834,12837],{},"反過來先刪快取、再更新資料庫，在這兩步之間如果剛好有讀取請求進來，它會 Cache Miss、讀到資料庫的",[23,12835,12836],{},"舊值","、再把舊值寫回快取。資料庫更新完之後，快取裡就是一份舊資料。",[11,12839,12840,12841,12844,12845,12848],{},"「先更新資料庫、再刪快取」也不是完全沒有問題，但出錯需要一個很巧的時序：讀取請求在快取過期後讀到舊值，",[23,12842,12843],{},"而且","它寫回快取的動作剛好落在寫入請求刪除快取",[23,12846,12847],{},"之後","。因為寫回快取通常比更新資料庫快得多，機率低很多。",[100,12850,12851],{"id":12851},"刪除失敗怎麼辦",[11,12853,12854],{},"資料庫更新成功了，刪除快取時 Redis 剛好連不上，快取就會一直是舊的。幾個層次的保險：",[66,12856,12857,12863,12869,12875],{},[69,12858,12859,12862],{},[23,12860,12861],{},"一定要設過期時間","：最差的情況，也只會舊到過期為止。這是最後一道防線",[69,12864,12865,12868],{},[23,12866,12867],{},"刪除失敗重試","：失敗時丟到訊息佇列，由背景重試刪除",[69,12870,12871,12874],{},[23,12872,12873],{},"延遲雙刪","：更新資料庫後刪一次，過幾百毫秒再刪一次，處理前面提到那個「讀取請求把舊值寫回去」的情況",[69,12876,12877,12880],{},[23,12878,12879],{},"訂閱資料庫異動","：由獨立的服務監聽資料異動（例如 CDC 或異動事件）來刪除快取，應用程式本身不用處理",[11,12882,12883,12884,12887],{},"要先接受一件事：",[23,12885,12886],{},"快取和資料庫之間只能做到最終一致","。像餘額、庫存這種一定要即時正確的資料，就不該從快取讀來做判斷。",[28,12889,12890],{"id":12890},"三個常見問題",[11,12892,12893],{},[1952,12894],{"alt":12895,"src":12896},"快取三大問題示意圖：快取穿透是請求查詢根本不存在的資料，例如 id=-1，每次都 Cache Miss 打到資料庫，解法是快取空值或布隆過濾器；快取擊穿是一個熱門 Key 過期的瞬間，上千個請求同時 Cache Miss 一起查資料庫，解法是互斥鎖只讓一個請求重建，或邏輯過期；快取雪崩是大量 Key 在同一時間過期或 Redis 整個掛掉，所有請求湧向資料庫，解法是過期時間加隨機值、Redis 高可用、本機快取與限流降級","\u002Fimages\u002Farticles\u002Fredis-cache-problems.svg",[100,12898,12900],{"id":12899},"快取穿透查詢根本不存在的資料","快取穿透：查詢根本不存在的資料",[11,12902,12903,12904,2276,12907,12910,12911,12914,12915,12918],{},"有人一直查 ",[15,12905,12906],{},"house:-1",[15,12908,12909],{},"house:99999999"," 這種",[23,12912,12913],{},"資料庫裡也沒有","的資料。因為查不到，就不會寫進快取，",[23,12916,12917],{},"每一次都會打到資料庫","。如果是惡意的大量請求，等於快取完全沒有作用。",[11,12920,12921,55],{},[23,12922,12923],{},"解法",[66,12925,12926,12932,12938],{},[69,12927,12928,12931],{},[23,12929,12930],{},"快取空值","：查不到時也寫一筆「空」進 Redis，設定比較短的過期時間（例如 1 分鐘）。下一次同樣的查詢在 Redis 就擋掉了",[69,12933,12934,12937],{},[23,12935,12936],{},"布隆過濾器（Bloom Filter）","：把所有存在的 ID 放進布隆過濾器，查詢前先問它，它說「不存在」就一定不存在，直接回傳。適合 ID 範圍很大、惡意請求可能每次都換不同 ID 的情況",[69,12939,12940,12943],{},[23,12941,12942],{},"參數驗證","：ID 小於 0、格式不對的，在 API 入口就擋掉",[100,12945,12947],{"id":12946},"快取擊穿熱門-key-過期的那一瞬間","快取擊穿：熱門 Key 過期的那一瞬間",[11,12949,12950,12951,12954,12955,12958],{},"首頁的熱門物件、活動頁面，每秒有上千個請求在讀",[23,12952,12953],{},"同一個 Key","。它一過期，這一千個請求",[23,12956,12957],{},"同時"," Cache Miss，同時去查資料庫、同時寫回快取。資料庫在那一秒突然多了一千個一模一樣的查詢。",[11,12960,12961,55],{},[23,12962,12923],{},[66,12964,12965,12974,12984],{},[69,12966,12967,12970,12971,12973],{},[23,12968,12969],{},"互斥鎖","：Cache Miss 時，只讓",[23,12972,6544],{},"請求去查資料庫並重建快取，其他請求等它完成後再讀快取",[69,12975,12976,12979,12980,12983],{},[23,12977,12978],{},"邏輯過期","：Redis 的 Key 不設過期時間，而是在值裡放一個「邏輯過期時間」。讀到已經邏輯過期的資料時，",[23,12981,12982],{},"先回傳舊值","，再由一個背景工作去更新。使用者永遠不會遇到 Cache Miss，代價是短時間內會讀到舊資料",[69,12985,12986,12989],{},[23,12987,12988],{},"熱門資料預先載入","：已知會很熱門的資料（例如活動開始），在活動前就先寫進快取",[100,12991,12993],{"id":12992},"快取雪崩大量-key-同時失效","快取雪崩：大量 Key 同時失效",[11,12995,12996,12997,13000],{},"和擊穿不同，雪崩是",[23,12998,12999],{},"很多 Key 同時","沒了：",[66,13002,13003,13006],{},[69,13004,13005],{},"批次匯入的資料全部設定了相同的過期時間，一小時後同時過期",[69,13007,13008],{},"Redis 本身掛了，或是重啟後記憶體是空的",[11,13010,13011],{},"所有請求一起湧向資料庫，資料庫被壓垮，接著依賴資料庫的服務也一起倒下。",[11,13013,13014,55],{},[23,13015,12923],{},[66,13017,13018,13024,13030,13040],{},[69,13019,13020,13023],{},[23,13021,13022],{},"過期時間加隨機值","：原本 10 分鐘，改成 10 分鐘加上 0～2 分鐘的隨機秒數，讓過期時間分散開來",[69,13025,13026,13029],{},[23,13027,13028],{},"Redis 高可用","：用 Sentinel 或 Cluster，單一節點掛掉時自動切換",[69,13031,13032,13035,13036,13039],{},[23,13033,13034],{},"多層快取","：應用程式內再加一層短時間的本機快取（",[15,13037,13038],{},"IMemoryCache","），Redis 短暫不可用時還能撐一下",[69,13041,13042,13045,13046,13049],{},[23,13043,13044],{},"限流與降級","：資料庫前面加上限流，超過的請求直接回傳預設內容或錯誤，",[23,13047,13048],{},"保護資料庫不被打垮","，比全部一起倒下好",[28,13051,13052],{"id":13052},"把三個解法放在一起",[36,13054,13056],{"className":3145,"code":13055,"language":3147,"meta":44,"style":44},"private static readonly ConcurrentDictionary\u003Cstring, SemaphoreSlim> Locks = new();\nprivate const string NullMarker = \"__null__\";\n\npublic async Task\u003CHouse?> GetHouseAsync(int id)\n{\n    if (id \u003C= 0) return null;  \u002F\u002F 參數驗證，擋掉明顯不存在的 ID\n\n    var key = $\"house:{id}\";\n    var cached = await _redis.StringGetAsync(key);\n    if (cached.HasValue)\n        return cached == NullMarker ? null : JsonSerializer.Deserialize\u003CHouse>(cached.ToString());\n\n    \u002F\u002F 擊穿：同一個 Key 只讓一個請求去查資料庫（這是單一實例內的鎖）\n    var gate = Locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));\n    await gate.WaitAsync();\n    try\n    {\n        \u002F\u002F 拿到鎖之後再檢查一次，前一個請求可能已經重建好了\n        cached = await _redis.StringGetAsync(key);\n        if (cached.HasValue)\n            return cached == NullMarker ? null : JsonSerializer.Deserialize\u003CHouse>(cached.ToString());\n\n        var house = await _db.Houses.FindAsync(id);\n        if (house is null)\n        {\n            \u002F\u002F 穿透：不存在也快取，但時間短一點\n            await _redis.StringSetAsync(key, NullMarker, TimeSpan.FromMinutes(1));\n            return null;\n        }\n\n        \u002F\u002F 雪崩：過期時間加上隨機值，避免同時過期\n        var ttl = TimeSpan.FromMinutes(10) + TimeSpan.FromSeconds(Random.Shared.Next(0, 120));\n        await _redis.StringSetAsync(key, JsonSerializer.Serialize(house), ttl);\n        return house;\n    }\n    finally\n    {\n        gate.Release();\n    }\n}\n",[15,13057,13058,13089,13109,13113,13137,13141,13166,13170,13190,13206,13212,13247,13251,13256,13293,13305,13309,13313,13318,13333,13340,13371,13375,13392,13404,13408,13413,13432,13440,13444,13448,13453,13496,13511,13517,13521,13525,13530,13541,13546],{"__ignoreMap":44},[376,13059,13060,13063,13065,13067,13070,13072,13074,13076,13078,13080,13083,13085,13087],{"class":378,"line":379},[376,13061,13062],{"class":3196},"private",[376,13064,3411],{"class":3196},[376,13066,12197],{"class":3196},[376,13068,13069],{"class":1020}," ConcurrentDictionary",[376,13071,3176],{"class":393},[376,13073,5012],{"class":3196},[376,13075,3188],{"class":393},[376,13077,8983],{"class":1020},[376,13079,3288],{"class":393},[376,13081,13082],{"class":1020},"Locks",[376,13084,3313],{"class":3196},[376,13086,4692],{"class":3196},[376,13088,3532],{"class":393},[376,13090,13091,13093,13096,13099,13102,13104,13107],{"class":378,"line":386},[376,13092,13062],{"class":3196},[376,13094,13095],{"class":3196}," const",[376,13097,13098],{"class":3196}," string",[376,13100,13101],{"class":1020}," NullMarker",[376,13103,3313],{"class":3196},[376,13105,13106],{"class":414}," \"__null__\"",[376,13108,3428],{"class":393},[376,13110,13111],{"class":378,"line":397},[376,13112,1036],{"emptyLinePlaceholder":1035},[376,13114,13115,13117,13119,13121,13123,13125,13127,13129,13131,13133,13135],{"class":378,"line":405},[376,13116,3274],{"class":3196},[376,13118,3277],{"class":3196},[376,13120,3280],{"class":1020},[376,13122,3176],{"class":393},[376,13124,12489],{"class":1020},[376,13126,12492],{"class":393},[376,13128,12495],{"class":1020},[376,13130,3220],{"class":393},[376,13132,5089],{"class":3196},[376,13134,12502],{"class":1020},[376,13136,3319],{"class":393},[376,13138,13139],{"class":378,"line":418},[376,13140,3324],{"class":393},[376,13142,13143,13145,13148,13151,13154,13156,13159,13161,13163],{"class":378,"line":429},[376,13144,3477],{"class":3196},[376,13146,13147],{"class":393}," (id ",[376,13149,13150],{"class":3196},"\u003C=",[376,13152,13153],{"class":560}," 0",[376,13155,5662],{"class":393},[376,13157,13158],{"class":3196},"return",[376,13160,12617],{"class":560},[376,13162,9407],{"class":393},[376,13164,13165],{"class":382},"\u002F\u002F 參數驗證，擋掉明顯不存在的 ID\n",[376,13167,13168],{"class":378,"line":437},[376,13169,1036],{"emptyLinePlaceholder":1035},[376,13171,13172,13174,13176,13178,13180,13182,13184,13186,13188],{"class":378,"line":447},[376,13173,5061],{"class":3196},[376,13175,9313],{"class":1020},[376,13177,3313],{"class":3196},[376,13179,12519],{"class":414},[376,13181,4729],{"class":414},[376,13183,12524],{"class":393},[376,13185,4741],{"class":414},[376,13187,4744],{"class":414},[376,13189,3428],{"class":393},[376,13191,13192,13194,13196,13198,13200,13202,13204],{"class":378,"line":531},[376,13193,5061],{"class":3196},[376,13195,12537],{"class":1020},[376,13197,3313],{"class":3196},[376,13199,9704],{"class":3196},[376,13201,9393],{"class":393},[376,13203,12546],{"class":1020},[376,13205,12549],{"class":393},[376,13207,13208,13210],{"class":378,"line":542},[376,13209,3477],{"class":3196},[376,13211,12556],{"class":393},[376,13213,13214,13216,13219,13222,13225,13228,13230,13233,13235,13237,13239,13241,13243,13245],{"class":378,"line":552},[376,13215,3485],{"class":3196},[376,13217,13218],{"class":393}," cached ",[376,13220,13221],{"class":3196},"==",[376,13223,13224],{"class":393}," NullMarker ",[376,13226,13227],{"class":3196},"?",[376,13229,12617],{"class":560},[376,13231,13232],{"class":3196}," :",[376,13234,12563],{"class":393},[376,13236,12566],{"class":1020},[376,13238,3176],{"class":393},[376,13240,12489],{"class":1020},[376,13242,12573],{"class":393},[376,13244,9349],{"class":1020},[376,13246,12018],{"class":393},[376,13248,13249],{"class":378,"line":564},[376,13250,1036],{"emptyLinePlaceholder":1035},[376,13252,13253],{"class":378,"line":575},[376,13254,13255],{"class":382},"    \u002F\u002F 擊穿：同一個 Key 只讓一個請求去查資料庫（這是單一實例內的鎖）\n",[376,13257,13258,13260,13263,13265,13268,13271,13274,13276,13278,13280,13283,13285,13287,13289,13291],{"class":378,"line":583},[376,13259,5061],{"class":3196},[376,13261,13262],{"class":1020}," gate",[376,13264,3313],{"class":3196},[376,13266,13267],{"class":393}," Locks.",[376,13269,13270],{"class":1020},"GetOrAdd",[376,13272,13273],{"class":393},"(key, ",[376,13275,4994],{"class":1020},[376,13277,3246],{"class":3196},[376,13279,4692],{"class":3196},[376,13281,13282],{"class":1020}," SemaphoreSlim",[376,13284,3220],{"class":393},[376,13286,892],{"class":560},[376,13288,3188],{"class":393},[376,13290,892],{"class":560},[376,13292,3839],{"class":393},[376,13294,13295,13297,13300,13303],{"class":378,"line":597},[376,13296,3329],{"class":3196},[376,13298,13299],{"class":393}," gate.",[376,13301,13302],{"class":1020},"WaitAsync",[376,13304,3532],{"class":393},[376,13306,13307],{"class":378,"line":605},[376,13308,5699],{"class":3196},[376,13310,13311],{"class":378,"line":616},[376,13312,5704],{"class":393},[376,13314,13315],{"class":378,"line":624},[376,13316,13317],{"class":382},"        \u002F\u002F 拿到鎖之後再檢查一次，前一個請求可能已經重建好了\n",[376,13319,13320,13323,13325,13327,13329,13331],{"class":378,"line":635},[376,13321,13322],{"class":393},"        cached ",[376,13324,3240],{"class":3196},[376,13326,9704],{"class":3196},[376,13328,9393],{"class":393},[376,13330,12546],{"class":1020},[376,13332,12549],{"class":393},[376,13334,13335,13338],{"class":378,"line":5815},[376,13336,13337],{"class":3196},"        if",[376,13339,12556],{"class":393},[376,13341,13342,13345,13347,13349,13351,13353,13355,13357,13359,13361,13363,13365,13367,13369],{"class":378,"line":7233},[376,13343,13344],{"class":3196},"            return",[376,13346,13218],{"class":393},[376,13348,13221],{"class":3196},[376,13350,13224],{"class":393},[376,13352,13227],{"class":3196},[376,13354,12617],{"class":560},[376,13356,13232],{"class":3196},[376,13358,12563],{"class":393},[376,13360,12566],{"class":1020},[376,13362,3176],{"class":393},[376,13364,12489],{"class":1020},[376,13366,12573],{"class":393},[376,13368,9349],{"class":1020},[376,13370,12018],{"class":393},[376,13372,13373],{"class":378,"line":7243},[376,13374,1036],{"emptyLinePlaceholder":1035},[376,13376,13377,13380,13382,13384,13386,13388,13390],{"class":378,"line":7251},[376,13378,13379],{"class":3196},"        var",[376,13381,12588],{"class":1020},[376,13383,3313],{"class":3196},[376,13385,9704],{"class":3196},[376,13387,12595],{"class":393},[376,13389,12598],{"class":1020},[376,13391,12601],{"class":393},[376,13393,13394,13396,13398,13400,13402],{"class":378,"line":7721},[376,13395,13337],{"class":3196},[376,13397,12608],{"class":393},[376,13399,12611],{"class":3196},[376,13401,12617],{"class":560},[376,13403,3319],{"class":393},[376,13405,13406],{"class":378,"line":7729},[376,13407,5721],{"class":393},[376,13409,13410],{"class":378,"line":7740},[376,13411,13412],{"class":382},"            \u002F\u002F 穿透：不存在也快取，但時間短一點\n",[376,13414,13415,13417,13419,13421,13424,13426,13428,13430],{"class":378,"line":9569},[376,13416,9481],{"class":3196},[376,13418,9393],{"class":393},[376,13420,12628],{"class":1020},[376,13422,13423],{"class":393},"(key, NullMarker, TimeSpan.",[376,13425,12640],{"class":1020},[376,13427,3220],{"class":393},[376,13429,892],{"class":560},[376,13431,3839],{"class":393},[376,13433,13434,13436,13438],{"class":378,"line":9581},[376,13435,13344],{"class":3196},[376,13437,12617],{"class":560},[376,13439,3428],{"class":393},[376,13441,13442],{"class":378,"line":9590},[376,13443,5761],{"class":393},[376,13445,13446],{"class":378,"line":9595},[376,13447,1036],{"emptyLinePlaceholder":1035},[376,13449,13450],{"class":378,"line":9600},[376,13451,13452],{"class":382},"        \u002F\u002F 雪崩：過期時間加上隨機值，避免同時過期\n",[376,13454,13455,13457,13460,13462,13464,13466,13468,13470,13472,13475,13477,13479,13482,13485,13487,13489,13491,13494],{"class":378,"line":9605},[376,13456,13379],{"class":3196},[376,13458,13459],{"class":1020}," ttl",[376,13461,3313],{"class":3196},[376,13463,9368],{"class":393},[376,13465,12640],{"class":1020},[376,13467,3220],{"class":393},[376,13469,361],{"class":560},[376,13471,5662],{"class":393},[376,13473,13474],{"class":3196},"+",[376,13476,9368],{"class":393},[376,13478,3832],{"class":1020},[376,13480,13481],{"class":393},"(Random.Shared.",[376,13483,13484],{"class":1020},"Next",[376,13486,3220],{"class":393},[376,13488,4975],{"class":560},[376,13490,3188],{"class":393},[376,13492,13493],{"class":560},"120",[376,13495,3839],{"class":393},[376,13497,13498,13500,13502,13504,13506,13508],{"class":378,"line":9615},[376,13499,9572],{"class":3196},[376,13501,9393],{"class":393},[376,13503,12628],{"class":1020},[376,13505,12631],{"class":393},[376,13507,12634],{"class":1020},[376,13509,13510],{"class":393},"(house), ttl);\n",[376,13512,13513,13515],{"class":378,"line":9639},[376,13514,3485],{"class":3196},[376,13516,12657],{"class":393},[376,13518,13519],{"class":378,"line":9651},[376,13520,5766],{"class":393},[376,13522,13523],{"class":378,"line":9656},[376,13524,5784],{"class":3196},[376,13526,13528],{"class":378,"line":13527},37,[376,13529,5704],{"class":393},[376,13531,13533,13536,13539],{"class":378,"line":13532},38,[376,13534,13535],{"class":393},"        gate.",[376,13537,13538],{"class":1020},"Release",[376,13540,3532],{"class":393},[376,13542,13544],{"class":378,"line":13543},39,[376,13545,5766],{"class":393},[376,13547,13549],{"class":378,"line":13548},40,[376,13550,3381],{"class":393},[11,13552,13553,13554,13556,13557,13560],{},"這裡的 ",[15,13555,8983],{}," 只在",[23,13558,13559],{},"同一個應用程式實例內","有效。部署了 10 個 Pod，最多就會有 10 個請求同時去查資料庫——通常這已經足夠，比一千個好太多了。如果連這 10 個都不能接受，才需要用 Redis 做跨實例的分散式鎖。",[28,13562,13564],{"id":13563},"使用情境物件詳細頁","使用情境：物件詳細頁",[11,13566,13567],{},"物件詳細頁是房仲網站流量最大的頁面之一：",[66,13569,13570,13575,13585,13591,13597],{},[69,13571,13572,13574],{},[23,13573,12440],{},"：Cache-Aside，過期時間 10 分鐘加隨機值",[69,13576,13577,13580,13581,13584],{},[23,13578,13579],{},"物件改價、下架","：更新資料庫後刪除 ",[15,13582,13583],{},"house:{id}","；同時發出物件異動事件，由同步服務再刪一次，處理刪除失敗與延遲雙刪的情況",[69,13586,13587,13590],{},[23,13588,13589],{},"爬蟲與惡意請求","：ID 驗證加上空值快取，不存在的物件不會一直打到資料庫",[69,13592,13593,13596],{},[23,13594,13595],{},"新建案上架、廣告曝光","：預先把物件寫進快取，並用互斥鎖避免過期瞬間的大量查詢",[69,13598,13599,13602],{},[23,13600,13601],{},"Redis 維護或故障","：Pod 內有 30 秒的本機快取，資料庫前面有限流，最差的情況是部分請求變慢，而不是整個網站掛掉",[28,13604,4494],{"id":4494},[66,13606,13607,13613,13619,13625,13634],{},[69,13608,13609,13612],{},[23,13610,13611],{},"不是所有資料都適合快取","：讀多寫少、可以接受短暫不一致的才適合。寫入頻繁的資料，快取一直被刪，命中率很低，反而多了一次 Redis 往返",[69,13614,13615,13618],{},[23,13616,13617],{},"監控命中率","：命中率太低代表 Key 設計或過期時間有問題，快取只是多一層開銷",[69,13620,13621,13624],{},[23,13622,13623],{},"快取的值不要太大","：一個 Key 存好幾 MB 的 JSON，序列化、網路傳輸都很慢，還會變成 Big Key。只快取頁面真正需要的欄位",[69,13626,13627,13630,13631],{},[23,13628,13629],{},"序列化的格式要有版本概念","：物件類別加了欄位、改了型別，部署新版本後讀到舊格式的快取可能會反序列化失敗。改動結構時可以在 Key 加上版本號，例如 ",[15,13632,13633],{},"house:v2:{id}",[69,13635,13636,13639],{},[23,13637,13638],{},"Redis 掛掉不能讓整個服務掛掉","：連不上 Redis 時，要降級成直接查資料庫（搭配限流），而不是直接拋出例外",[28,13641,1890],{"id":1890},[11,13643,13644],{},"用 Cache-Aside 時，讀取先查快取再查資料庫並寫回，寫入先更新資料庫再刪除快取，並一定要設過期時間當最後防線；查不存在的資料用空值快取或布隆過濾器防穿透，熱門 Key 過期用互斥鎖或邏輯過期防擊穿，大量 Key 同時失效用隨機過期時間、高可用與限流防雪崩。",[1132,13646,13647],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":44,"searchDepth":386,"depth":386,"links":13649},[13650,13655,13660,13661,13662,13663],{"id":12421,"depth":386,"text":12422,"children":13651},[13652,13653,13654],{"id":12756,"depth":397,"text":12757},{"id":12829,"depth":397,"text":12830},{"id":12851,"depth":397,"text":12851},{"id":12890,"depth":386,"text":12890,"children":13656},[13657,13658,13659],{"id":12899,"depth":397,"text":12900},{"id":12946,"depth":397,"text":12947},{"id":12992,"depth":397,"text":12993},{"id":13052,"depth":386,"text":13052},{"id":13563,"depth":386,"text":13564},{"id":4494,"depth":386,"text":4494},{"id":1890,"depth":386,"text":1890},"2024-07-09","Redis 的資料結構與基本操作可以先參考 Redis 不只是快取。這篇專注在最常見的用法：把它放在資料庫前面當快取。",{},"\u002Farticles\u002Fredis-cache-penetration-breakdown-avalanche",{"title":12405,"description":13665},"articles\u002Fredis-cache-penetration-breakdown-avalanche",[10102,13671,13672,3039],"快取","高併發","WYOD_mmruhvreq7FY3aRyNd1nayFate3CefjwdmgBmo",{"id":13675,"title":13676,"body":13677,"date":15498,"description":44,"excerpt":1156,"extension":1157,"meta":15499,"navigation":1035,"path":15500,"seo":15501,"stem":15502,"tags":15503,"__hash__":15506},"articles\u002Farticles\u002Fcontainerd-and-nerdctl.md","從 Docker 換到 containerd + nerdctl：架構差異、安裝 runc 與 CNI，以及移除 Docker 後的網路問題",{"type":8,"value":13678,"toc":15470},[13679,13683,13686,13700,13711,13715,13721,13728,13800,13811,13814,13831,13835,13838,13904,13921,13925,13929,13936,14087,14090,14102,14126,14129,14134,14214,14218,14227,14239,14242,14256,14265,14343,14359,14363,14383,14475,14497,14501,14510,14528,14621,14627,14630,14678,14682,14685,14688,14717,14720,14731,14760,14791,14798,14802,14805,14887,14891,14893,14899,14920,14928,14930,14936,14971,14984,14987,14990,14995,15000,15030,15035,15093,15098,15115,15193,15218,15223,15274,15294,15299,15395,15406,15409,15433,15436,15459,15461,15467],[28,13680,13682],{"id":13681},"為什麼不用-docker-了","為什麼不用 Docker 了",[11,13684,13685],{},"以前部署容器，第一步一定是裝 Docker。但這次換掉它有兩個原因：",[66,13687,13688,13694],{},[69,13689,13690,13693],{},[23,13691,13692],{},"Docker 太肥大了","：這些主機只需要「拉映像檔、跑容器」，Docker 卻附帶了 dockerd 常駐程式、自己的網路與儲存管理、Swarm 等一整套功能，多一層元件就多一份要維護、要更新、要擔心的東西",[69,13695,13696,13699],{},[23,13697,13698],{},"和公司的 Harbor 不相容","：公司的 Harbor 一直沒有升版，在我們的環境裡，安裝 docker-ce 18.06 以上的版本就無法從 Harbor 拉取映像檔。但為了配合舊的 Harbor 一直停在舊版 Docker，也不是長久之計",[11,13701,13702,13703,13706,13707,13710],{},"而 Docker 底層真正在管理容器的，其實就是 ",[23,13704,13705],{},"containerd","。Kubernetes 從 1.24 開始移除了 dockershim，也已經直接使用 containerd 這類 CRI 執行環境。所以只要把 Docker 拿掉、直接用 containerd，再搭配一個指令和 Docker 幾乎一樣的 CLI——",[23,13708,13709],{},"nerdctl","——就能用更少的元件做到一樣的事。",[28,13712,13714],{"id":13713},"docker-和-containerd-是什麼關係","Docker 和 containerd 是什麼關係",[11,13716,13717],{},[1952,13718],{"alt":13719,"src":13720},"Docker 與 containerd + nerdctl 的架構比較：使用 Docker 時，docker CLI 呼叫常駐的 dockerd，dockerd 再呼叫 containerd，由 containerd-shim 啟動 runc 建立容器，網路由 dockerd 自己管理 docker0 橋接網卡與 iptables 規則；改用 nerdctl 後，nerdctl 直接呼叫 containerd，少了 dockerd 這一層，容器同樣由 containerd-shim 與 runc 建立，網路則交給 CNI plugins 建立 nerdctl0 橋接網卡與 iptables 規則；Kubernetes 的 kubelet 也是透過 CRI 直接呼叫同一個 containerd","\u002Fimages\u002Farticles\u002Fcontainerd-architecture.svg",[11,13722,13723,13724,13727],{},"一個 ",[15,13725,13726],{},"docker run"," 背後其實經過好幾層：",[132,13729,13730,13739],{},[135,13731,13732],{},[138,13733,13734,13736],{},[141,13735,5949],{},[141,13737,13738],{},"負責什麼",[152,13740,13741,13751,13761,13770,13780,13790],{},[138,13742,13743,13748],{},[157,13744,13745],{},[23,13746,13747],{},"docker CLI",[157,13749,13750],{},"使用者下指令的工具",[138,13752,13753,13758],{},[157,13754,13755],{},[23,13756,13757],{},"dockerd",[157,13759,13760],{},"Docker 的常駐程式，處理 API、建置映像檔、管理網路與 Volume",[138,13762,13763,13767],{},[157,13764,13765],{},[23,13766,13705],{},[157,13768,13769],{},"管理容器的生命週期：拉映像檔、管理快照、建立與監控容器",[138,13771,13772,13777],{},[157,13773,13774],{},[23,13775,13776],{},"containerd-shim",[157,13778,13779],{},"每個容器一個，讓 containerd 重啟時容器不會跟著停",[138,13781,13782,13787],{},[157,13783,13784],{},[23,13785,13786],{},"runc",[157,13788,13789],{},"真正呼叫 Linux 核心（namespace、cgroups）建立容器的低階執行環境，遵循 OCI 規範",[138,13791,13792,13797],{},[157,13793,13794],{},[23,13795,13796],{},"CNI plugins",[157,13798,13799],{},"負責容器的網路：建立橋接網卡、分配 IP、設定 Port 對應",[11,13801,13802,13803,13806,13807,13810],{},"改用 containerd + nerdctl 之後，",[23,13804,13805],{},"dockerd 這一層就不見了","，nerdctl 直接呼叫 containerd。容器的建立方式完全一樣，還是 containerd-shim 加 runc；差別在於網路：Docker 的網路是 dockerd 自己管的，",[23,13808,13809],{},"containerd 本身不管網路","，所以要另外安裝 CNI plugins，由 nerdctl 呼叫它們來建立網路。",[11,13812,13813],{},"這也是安裝時需要三樣東西的原因：",[211,13815,13816,13821,13826],{},[69,13817,13818,13820],{},[23,13819,13705],{},"：管理容器",[69,13822,13823,13825],{},[23,13824,13786],{},"：建立容器",[69,13827,13828,13830],{},[23,13829,13796],{},"：容器網路",[28,13832,13834],{"id":13833},"和-containerd-互動的三種-cli","和 containerd 互動的三種 CLI",[11,13836,13837],{},"containerd 有三種常見的命令列工具，用途不同：",[132,13839,13840,13851],{},[135,13841,13842],{},[138,13843,13844,13847,13849],{},[141,13845,13846],{},"工具",[141,13848,2898],{},[141,13850,6174],{},[152,13852,13853,13866,13879],{},[138,13854,13855,13860,13863],{},[157,13856,13857],{},[15,13858,13859],{},"ctr",[157,13861,13862],{},"containerd 內建的除錯工具",[157,13864,13865],{},"低階除錯，指令和 Docker 差很多，也不處理網路",[138,13867,13868,13873,13876],{},[157,13869,13870],{},[15,13871,13872],{},"crictl",[157,13874,13875],{},"透過 CRI 介面操作，是給 Kubernetes 用的",[157,13877,13878],{},"在 K8s 節點上查看 kubelet 建立的 Pod 與容器",[138,13880,13881,13885,13888],{},[157,13882,13883],{},[15,13884,13709],{},[157,13886,13887],{},"和 Docker CLI 相容的工具",[157,13889,13890,13893,13894,2276,13897,2276,13900,13903],{},[23,13891,13892],{},"取代 docker 指令","，支援 ",[15,13895,13896],{},"run -p",[15,13898,13899],{},"compose",[15,13901,13902],{},"login"," 等",[11,13905,13906,13907,13909,13910,13913,13914,13917,13918,114],{},"日常使用選 nerdctl 就對了：",[15,13908,13726],{}," 改成 ",[15,13911,13912],{},"nerdctl run","，大部分的參數都一樣，連 ",[15,13915,13916],{},"nerdctl compose up"," 都可以直接吃原本的 ",[15,13919,13920],{},"docker-compose.yml",[28,13922,13924],{"id":13923},"安裝-containerd","安裝 containerd",[100,13926,13928],{"id":13927},"加入-docker-的-apt-套件庫","加入 Docker 的 apt 套件庫",[11,13930,13931,13932,13935],{},"containerd 的 ",[15,13933,13934],{},"containerd.io"," 套件是由 Docker 的 apt 套件庫提供的，先加入金鑰與套件庫：",[36,13937,13939],{"className":1006,"code":13938,"language":1008,"meta":44,"style":44},"sudo apt-get update\nsudo apt-get install ca-certificates curl\nsudo install -m 0755 -d \u002Fetc\u002Fapt\u002Fkeyrings\nsudo curl -fsSL https:\u002F\u002Fdownload.docker.com\u002Flinux\u002Fubuntu\u002Fgpg -o \u002Fetc\u002Fapt\u002Fkeyrings\u002Fdocker.asc\nsudo chmod a+r \u002Fetc\u002Fapt\u002Fkeyrings\u002Fdocker.asc\n\necho \\\n  \"deb [arch=$(dpkg --print-architecture) signed-by=\u002Fetc\u002Fapt\u002Fkeyrings\u002Fdocker.asc] https:\u002F\u002Fdownload.docker.com\u002Flinux\u002Fubuntu \\\n  $(. \u002Fetc\u002Fos-release && echo \"$VERSION_CODENAME\") stable\" | \\\n  sudo tee \u002Fetc\u002Fapt\u002Fsources.list.d\u002Fdocker.list > \u002Fdev\u002Fnull\n",[15,13940,13941,13952,13967,13985,14004,14016,14020,14027,14044,14070],{"__ignoreMap":44},[376,13942,13943,13946,13949],{"class":378,"line":379},[376,13944,13945],{"class":1020},"sudo",[376,13947,13948],{"class":414}," apt-get",[376,13950,13951],{"class":414}," update\n",[376,13953,13954,13956,13958,13961,13964],{"class":378,"line":386},[376,13955,13945],{"class":1020},[376,13957,13948],{"class":414},[376,13959,13960],{"class":414}," install",[376,13962,13963],{"class":414}," ca-certificates",[376,13965,13966],{"class":414}," curl\n",[376,13968,13969,13971,13973,13976,13979,13982],{"class":378,"line":397},[376,13970,13945],{"class":1020},[376,13972,13960],{"class":414},[376,13974,13975],{"class":560}," -m",[376,13977,13978],{"class":560}," 0755",[376,13980,13981],{"class":560}," -d",[376,13983,13984],{"class":414}," \u002Fetc\u002Fapt\u002Fkeyrings\n",[376,13986,13987,13989,13992,13995,13998,14001],{"class":378,"line":405},[376,13988,13945],{"class":1020},[376,13990,13991],{"class":414}," curl",[376,13993,13994],{"class":560}," -fsSL",[376,13996,13997],{"class":414}," https:\u002F\u002Fdownload.docker.com\u002Flinux\u002Fubuntu\u002Fgpg",[376,13999,14000],{"class":560}," -o",[376,14002,14003],{"class":414}," \u002Fetc\u002Fapt\u002Fkeyrings\u002Fdocker.asc\n",[376,14005,14006,14008,14011,14014],{"class":378,"line":418},[376,14007,13945],{"class":1020},[376,14009,14010],{"class":414}," chmod",[376,14012,14013],{"class":414}," a+r",[376,14015,14003],{"class":414},[376,14017,14018],{"class":378,"line":429},[376,14019,1036],{"emptyLinePlaceholder":1035},[376,14021,14022,14025],{"class":378,"line":437},[376,14023,14024],{"class":560},"echo",[376,14026,6637],{"class":560},[376,14028,14029,14032,14035,14038,14041],{"class":378,"line":447},[376,14030,14031],{"class":414},"  \"deb [arch=$(",[376,14033,14034],{"class":1020},"dpkg",[376,14036,14037],{"class":560}," --print-architecture",[376,14039,14040],{"class":414},") signed-by=\u002Fetc\u002Fapt\u002Fkeyrings\u002Fdocker.asc] https:\u002F\u002Fdownload.docker.com\u002Flinux\u002Fubuntu ",[376,14042,14043],{"class":560},"\\\n",[376,14045,14046,14049,14051,14054,14056,14059,14062,14065,14068],{"class":378,"line":531},[376,14047,14048],{"class":414},"  $(",[376,14050,4735],{"class":560},[376,14052,14053],{"class":414}," \u002Fetc\u002Fos-release && ",[376,14055,14024],{"class":560},[376,14057,14058],{"class":414}," \"",[376,14060,14061],{"class":393},"$VERSION_CODENAME",[376,14063,14064],{"class":414},"\") stable\"",[376,14066,14067],{"class":3196}," |",[376,14069,6637],{"class":560},[376,14071,14072,14075,14078,14081,14084],{"class":378,"line":542},[376,14073,14074],{"class":1020},"  sudo",[376,14076,14077],{"class":414}," tee",[376,14079,14080],{"class":414}," \u002Fetc\u002Fapt\u002Fsources.list.d\u002Fdocker.list",[376,14082,14083],{"class":3196}," >",[376,14085,14086],{"class":414}," \u002Fdev\u002Fnull\n",[100,14088,13924],{"id":14089},"安裝-containerd-1",[11,14091,14092,14093,1540,14095,14098,14099,55],{},"只安裝 ",[15,14094,13934],{},[23,14096,14097],{},"不要","安裝 ",[15,14100,14101],{},"docker-ce",[36,14103,14105],{"className":1006,"code":14104,"language":1008,"meta":44,"style":44},"sudo apt-get update\nsudo apt-get install containerd.io\n",[15,14106,14107,14115],{"__ignoreMap":44},[376,14108,14109,14111,14113],{"class":378,"line":379},[376,14110,13945],{"class":1020},[376,14112,13948],{"class":414},[376,14114,13951],{"class":414},[376,14116,14117,14119,14121,14123],{"class":378,"line":386},[376,14118,13945],{"class":1020},[376,14120,13948],{"class":414},[376,14122,13960],{"class":414},[376,14124,14125],{"class":414}," containerd.io\n",[100,14127,14128],{"id":14128},"產生設定檔並啟動",[11,14130,14131,14133],{},[15,14132,13934],{}," 附的設定檔是給 Docker 用的精簡版，建議改用 containerd 的完整預設設定，之後要調整 Registry、cgroup 等設定才有地方改：",[36,14135,14137],{"className":1006,"code":14136,"language":1008,"meta":44,"style":44},"sudo mkdir -p \u002Fetc\u002Fcontainerd\ncontainerd config default | sudo tee \u002Fetc\u002Fcontainerd\u002Fconfig.toml > \u002Fdev\u002Fnull\n\nsudo systemctl daemon-reload\nsudo systemctl enable --now containerd\nsudo systemctl status containerd\n",[15,14138,14139,14152,14174,14178,14188,14203],{"__ignoreMap":44},[376,14140,14141,14143,14146,14149],{"class":378,"line":379},[376,14142,13945],{"class":1020},[376,14144,14145],{"class":414}," mkdir",[376,14147,14148],{"class":560}," -p",[376,14150,14151],{"class":414}," \u002Fetc\u002Fcontainerd\n",[376,14153,14154,14156,14158,14160,14162,14165,14167,14170,14172],{"class":378,"line":386},[376,14155,13705],{"class":1020},[376,14157,4687],{"class":414},[376,14159,3316],{"class":414},[376,14161,14067],{"class":3196},[376,14163,14164],{"class":1020}," sudo",[376,14166,14077],{"class":414},[376,14168,14169],{"class":414}," \u002Fetc\u002Fcontainerd\u002Fconfig.toml",[376,14171,14083],{"class":3196},[376,14173,14086],{"class":414},[376,14175,14176],{"class":378,"line":397},[376,14177,1036],{"emptyLinePlaceholder":1035},[376,14179,14180,14182,14185],{"class":378,"line":405},[376,14181,13945],{"class":1020},[376,14183,14184],{"class":414}," systemctl",[376,14186,14187],{"class":414}," daemon-reload\n",[376,14189,14190,14192,14194,14197,14200],{"class":378,"line":418},[376,14191,13945],{"class":1020},[376,14193,14184],{"class":414},[376,14195,14196],{"class":414}," enable",[376,14198,14199],{"class":560}," --now",[376,14201,14202],{"class":414}," containerd\n",[376,14204,14205,14207,14209,14212],{"class":378,"line":429},[376,14206,13945],{"class":1020},[376,14208,14184],{"class":414},[376,14210,14211],{"class":414}," status",[376,14213,14202],{"class":414},[28,14215,14217],{"id":14216},"安裝-runc","安裝 runc",[11,14219,14220,14222,14223,14226],{},[15,14221,13934],{}," 套件其實",[23,14224,14225],{},"已經附帶了 runc","，可以先確認：",[36,14228,14230],{"className":1006,"code":14229,"language":1008,"meta":44,"style":44},"runc --version\n",[15,14231,14232],{"__ignoreMap":44},[376,14233,14234,14236],{"class":378,"line":379},[376,14235,13786],{"class":1020},[376,14237,14238],{"class":560}," --version\n",[11,14240,14241],{},"如果是從 GitHub 下載 containerd 的二進位檔安裝（不透過 apt），或是需要更新 runc 版本，再手動安裝。先確認系統架構：",[36,14243,14245],{"className":1006,"code":14244,"language":1008,"meta":44,"style":44},"dpkg --print-architecture   # 例如 amd64、arm64\n",[15,14246,14247],{"__ignoreMap":44},[376,14248,14249,14251,14253],{"class":378,"line":379},[376,14250,14034],{"class":1020},[376,14252,14037],{"class":560},[376,14254,14255],{"class":382},"   # 例如 amd64、arm64\n",[11,14257,14258,14259,14264],{},"再到 ",[119,14260,14263],{"href":14261,"rel":14262},"https:\u002F\u002Fgithub.com\u002Fopencontainers\u002Frunc\u002Freleases",[3057],"runc Releases"," 選擇版本下載：",[36,14266,14268],{"className":1006,"code":14267,"language":1008,"meta":44,"style":44},"RUNC_VERSION=1.1.12\nARCH=$(dpkg --print-architecture)\n\nwget https:\u002F\u002Fgithub.com\u002Fopencontainers\u002Frunc\u002Freleases\u002Fdownload\u002Fv${RUNC_VERSION}\u002Frunc.${ARCH}\nsudo install -m 755 runc.${ARCH} \u002Fusr\u002Flocal\u002Fsbin\u002Frunc\nrunc --version\n",[15,14269,14270,14280,14296,14300,14317,14337],{"__ignoreMap":44},[376,14271,14272,14275,14277],{"class":378,"line":379},[376,14273,14274],{"class":393},"RUNC_VERSION",[376,14276,3240],{"class":3196},[376,14278,14279],{"class":414},"1.1.12\n",[376,14281,14282,14285,14287,14290,14292,14294],{"class":378,"line":386},[376,14283,14284],{"class":393},"ARCH",[376,14286,3240],{"class":3196},[376,14288,14289],{"class":393},"$(",[376,14291,14034],{"class":1020},[376,14293,14037],{"class":560},[376,14295,3319],{"class":393},[376,14297,14298],{"class":378,"line":397},[376,14299,1036],{"emptyLinePlaceholder":1035},[376,14301,14302,14305,14308,14311,14314],{"class":378,"line":405},[376,14303,14304],{"class":1020},"wget",[376,14306,14307],{"class":414}," https:\u002F\u002Fgithub.com\u002Fopencontainers\u002Frunc\u002Freleases\u002Fdownload\u002Fv",[376,14309,14310],{"class":393},"${RUNC_VERSION}",[376,14312,14313],{"class":414},"\u002Frunc.",[376,14315,14316],{"class":393},"${ARCH}\n",[376,14318,14319,14321,14323,14325,14328,14331,14334],{"class":378,"line":418},[376,14320,13945],{"class":1020},[376,14322,13960],{"class":414},[376,14324,13975],{"class":560},[376,14326,14327],{"class":560}," 755",[376,14329,14330],{"class":414}," runc.",[376,14332,14333],{"class":393},"${ARCH} ",[376,14335,14336],{"class":414},"\u002Fusr\u002Flocal\u002Fsbin\u002Frunc\n",[376,14338,14339,14341],{"class":378,"line":429},[376,14340,13786],{"class":1020},[376,14342,14238],{"class":560},[7381,14344,14345],{},[11,14346,14347,14348,14351,14352,14355,14356,14358],{},"Releases 頁面上也會列出 ",[15,14349,14350],{},"-rc"," 結尾的預覽版本（例如 ",[15,14353,14354],{},"v1.2.0-rc.1","），正式環境請選擇沒有 ",[15,14357,14350],{}," 的穩定版。",[28,14360,14362],{"id":14361},"安裝-cni-plugins","安裝 CNI plugins",[11,14364,14365,14366,14369,14370,14372,14373,14376,14377,14382],{},"containerd 不管網路，",[15,14367,14368],{},"nerdctl run -p 8080:80"," 這種 Port 對應、容器之間的網路，都是由 CNI plugins 處理。",[15,14371,13934],{}," ",[23,14374,14375],{},"沒有","附帶 CNI，一定要自己安裝，到 ",[119,14378,14381],{"href":14379,"rel":14380},"https:\u002F\u002Fgithub.com\u002Fcontainernetworking\u002Fplugins\u002Freleases",[3057],"CNI plugins Releases"," 選擇版本：",[36,14384,14386],{"className":1006,"code":14385,"language":1008,"meta":44,"style":44},"CNI_VERSION=1.5.0\nARCH=$(dpkg --print-architecture)\n\nwget https:\u002F\u002Fgithub.com\u002Fcontainernetworking\u002Fplugins\u002Freleases\u002Fdownload\u002Fv${CNI_VERSION}\u002Fcni-plugins-linux-${ARCH}-v${CNI_VERSION}.tgz\nsudo mkdir -p \u002Fopt\u002Fcni\u002Fbin\nsudo tar Cxzvf \u002Fopt\u002Fcni\u002Fbin cni-plugins-linux-${ARCH}-v${CNI_VERSION}.tgz\n",[15,14387,14388,14398,14412,14416,14440,14451],{"__ignoreMap":44},[376,14389,14390,14393,14395],{"class":378,"line":379},[376,14391,14392],{"class":393},"CNI_VERSION",[376,14394,3240],{"class":3196},[376,14396,14397],{"class":414},"1.5.0\n",[376,14399,14400,14402,14404,14406,14408,14410],{"class":378,"line":386},[376,14401,14284],{"class":393},[376,14403,3240],{"class":3196},[376,14405,14289],{"class":393},[376,14407,14034],{"class":1020},[376,14409,14037],{"class":560},[376,14411,3319],{"class":393},[376,14413,14414],{"class":378,"line":397},[376,14415,1036],{"emptyLinePlaceholder":1035},[376,14417,14418,14420,14423,14426,14429,14432,14435,14437],{"class":378,"line":405},[376,14419,14304],{"class":1020},[376,14421,14422],{"class":414}," https:\u002F\u002Fgithub.com\u002Fcontainernetworking\u002Fplugins\u002Freleases\u002Fdownload\u002Fv",[376,14424,14425],{"class":393},"${CNI_VERSION}",[376,14427,14428],{"class":414},"\u002Fcni-plugins-linux-",[376,14430,14431],{"class":393},"${ARCH}",[376,14433,14434],{"class":414},"-v",[376,14436,14425],{"class":393},[376,14438,14439],{"class":414},".tgz\n",[376,14441,14442,14444,14446,14448],{"class":378,"line":418},[376,14443,13945],{"class":1020},[376,14445,14145],{"class":414},[376,14447,14148],{"class":560},[376,14449,14450],{"class":414}," \u002Fopt\u002Fcni\u002Fbin\n",[376,14452,14453,14455,14458,14461,14464,14467,14469,14471,14473],{"class":378,"line":429},[376,14454,13945],{"class":1020},[376,14456,14457],{"class":414}," tar",[376,14459,14460],{"class":414}," Cxzvf",[376,14462,14463],{"class":414}," \u002Fopt\u002Fcni\u002Fbin",[376,14465,14466],{"class":414}," cni-plugins-linux-",[376,14468,14431],{"class":393},[376,14470,14434],{"class":414},[376,14472,14425],{"class":393},[376,14474,14439],{"class":414},[11,14476,14477,14480,14481,14484,14485,14488,14489,14492,14493,14496],{},[15,14478,14479],{},"\u002Fopt\u002Fcni\u002Fbin"," 是 nerdctl 預設尋找 CNI plugins 的位置。裡面會有 ",[15,14482,14483],{},"bridge","（建立橋接網卡）、",[15,14486,14487],{},"host-local","（分配 IP）、",[15,14490,14491],{},"portmap","（Port 對應）、",[15,14494,14495],{},"firewall"," 等執行檔，nerdctl 建立容器時會依序呼叫它們。",[28,14498,14500],{"id":14499},"安裝-nerdctl","安裝 nerdctl",[11,14502,14503,14504,14509],{},"到 ",[119,14505,14508],{"href":14506,"rel":14507},"https:\u002F\u002Fgithub.com\u002Fcontainerd\u002Fnerdctl\u002Freleases",[3057],"nerdctl Releases"," 下載。nerdctl 提供兩種版本：",[66,14511,14512,14520],{},[69,14513,14514,14519],{},[23,14515,14516],{},[15,14517,14518],{},"nerdctl-x.y.z-linux-amd64.tar.gz","：只有 nerdctl 本身，containerd、runc、CNI 要自己裝（也就是本篇的做法）",[69,14521,14522,14527],{},[23,14523,14524],{},[15,14525,14526],{},"nerdctl-full-x.y.z-linux-amd64.tar.gz","：一次附帶 containerd、runc、CNI plugins、BuildKit 等所有相依元件，適合全新的主機一次裝好",[36,14529,14531],{"className":1006,"code":14530,"language":1008,"meta":44,"style":44},"NERDCTL_VERSION=1.7.6\nARCH=$(dpkg --print-architecture)\n\nwget https:\u002F\u002Fgithub.com\u002Fcontainerd\u002Fnerdctl\u002Freleases\u002Fdownload\u002Fv${NERDCTL_VERSION}\u002Fnerdctl-${NERDCTL_VERSION}-linux-${ARCH}.tar.gz\nsudo tar Cxzvf \u002Fusr\u002Flocal\u002Fbin nerdctl-${NERDCTL_VERSION}-linux-${ARCH}.tar.gz nerdctl\n\nnerdctl version\n",[15,14532,14533,14543,14557,14561,14584,14610,14614],{"__ignoreMap":44},[376,14534,14535,14538,14540],{"class":378,"line":379},[376,14536,14537],{"class":393},"NERDCTL_VERSION",[376,14539,3240],{"class":3196},[376,14541,14542],{"class":414},"1.7.6\n",[376,14544,14545,14547,14549,14551,14553,14555],{"class":378,"line":386},[376,14546,14284],{"class":393},[376,14548,3240],{"class":3196},[376,14550,14289],{"class":393},[376,14552,14034],{"class":1020},[376,14554,14037],{"class":560},[376,14556,3319],{"class":393},[376,14558,14559],{"class":378,"line":397},[376,14560,1036],{"emptyLinePlaceholder":1035},[376,14562,14563,14565,14568,14571,14574,14576,14579,14581],{"class":378,"line":405},[376,14564,14304],{"class":1020},[376,14566,14567],{"class":414}," https:\u002F\u002Fgithub.com\u002Fcontainerd\u002Fnerdctl\u002Freleases\u002Fdownload\u002Fv",[376,14569,14570],{"class":393},"${NERDCTL_VERSION}",[376,14572,14573],{"class":414},"\u002Fnerdctl-",[376,14575,14570],{"class":393},[376,14577,14578],{"class":414},"-linux-",[376,14580,14431],{"class":393},[376,14582,14583],{"class":414},".tar.gz\n",[376,14585,14586,14588,14590,14592,14595,14598,14600,14602,14604,14607],{"class":378,"line":418},[376,14587,13945],{"class":1020},[376,14589,14457],{"class":414},[376,14591,14460],{"class":414},[376,14593,14594],{"class":414}," \u002Fusr\u002Flocal\u002Fbin",[376,14596,14597],{"class":414}," nerdctl-",[376,14599,14570],{"class":393},[376,14601,14578],{"class":414},[376,14603,14431],{"class":393},[376,14605,14606],{"class":414},".tar.gz",[376,14608,14609],{"class":414}," nerdctl\n",[376,14611,14612],{"class":378,"line":429},[376,14613,1036],{"emptyLinePlaceholder":1035},[376,14615,14616,14618],{"class":378,"line":437},[376,14617,13709],{"class":1020},[376,14619,14620],{"class":414}," version\n",[11,14622,14623,14626],{},[15,14624,14625],{},"nerdctl version"," 同時顯示 Client 與 Server（containerd）的版本，兩者都有出現代表 nerdctl 已經成功連上 containerd。",[100,14628,14629],{"id":14629},"驗證",[36,14631,14633],{"className":1006,"code":14632,"language":1008,"meta":44,"style":44},"sudo nerdctl run -d --name web -p 8080:80 nginx:alpine\nsudo nerdctl ps\ncurl http:\u002F\u002Flocalhost:8080\n",[15,14634,14635,14661,14670],{"__ignoreMap":44},[376,14636,14637,14639,14642,14645,14647,14650,14653,14655,14658],{"class":378,"line":379},[376,14638,13945],{"class":1020},[376,14640,14641],{"class":414}," nerdctl",[376,14643,14644],{"class":414}," run",[376,14646,13981],{"class":560},[376,14648,14649],{"class":560}," --name",[376,14651,14652],{"class":414}," web",[376,14654,14148],{"class":560},[376,14656,14657],{"class":414}," 8080:80",[376,14659,14660],{"class":414}," nginx:alpine\n",[376,14662,14663,14665,14667],{"class":378,"line":386},[376,14664,13945],{"class":1020},[376,14666,14641],{"class":414},[376,14668,14669],{"class":414}," ps\n",[376,14671,14672,14675],{"class":378,"line":397},[376,14673,14674],{"class":1020},"curl",[376,14676,14677],{"class":414}," http:\u002F\u002Flocalhost:8080\n",[28,14679,14681],{"id":14680},"連線公司的-harbor","連線公司的 Harbor",[100,14683,14684],{"id":14684},"登入",[11,14686,14687],{},"和 Docker 一樣：",[36,14689,14691],{"className":1006,"code":14690,"language":1008,"meta":44,"style":44},"sudo nerdctl login harbor.example.com\nsudo nerdctl pull harbor.example.com\u002Frealty\u002Fhouse-api:1.3.0\n",[15,14692,14693,14705],{"__ignoreMap":44},[376,14694,14695,14697,14699,14702],{"class":378,"line":379},[376,14696,13945],{"class":1020},[376,14698,14641],{"class":414},[376,14700,14701],{"class":414}," login",[376,14703,14704],{"class":414}," harbor.example.com\n",[376,14706,14707,14709,14711,14714],{"class":378,"line":386},[376,14708,13945],{"class":1020},[376,14710,14641],{"class":414},[376,14712,14713],{"class":414}," pull",[376,14715,14716],{"class":414}," harbor.example.com\u002Frealty\u002Fhouse-api:1.3.0\n",[100,14718,14719],{"id":14719},"自簽憑證",[11,14721,14722,14723,14726,14727,14730],{},"公司內部的 Harbor 常用自簽憑證，拉取時會出現 ",[15,14724,14725],{},"x509: certificate signed by unknown authority","。nerdctl 會讀取 ",[15,14728,14729],{},"\u002Fetc\u002Fcontainerd\u002Fcerts.d\u002F"," 底下的設定，把公司的 CA 憑證放進對應的資料夾：",[36,14732,14734],{"className":1006,"code":14733,"language":1008,"meta":44,"style":44},"sudo mkdir -p \u002Fetc\u002Fcontainerd\u002Fcerts.d\u002Fharbor.example.com\nsudo cp company-ca.crt \u002Fetc\u002Fcontainerd\u002Fcerts.d\u002Fharbor.example.com\u002Fca.crt\n",[15,14735,14736,14747],{"__ignoreMap":44},[376,14737,14738,14740,14742,14744],{"class":378,"line":379},[376,14739,13945],{"class":1020},[376,14741,14145],{"class":414},[376,14743,14148],{"class":560},[376,14745,14746],{"class":414}," \u002Fetc\u002Fcontainerd\u002Fcerts.d\u002Fharbor.example.com\n",[376,14748,14749,14751,14754,14757],{"class":378,"line":386},[376,14750,13945],{"class":1020},[376,14752,14753],{"class":414}," cp",[376,14755,14756],{"class":414}," company-ca.crt",[376,14758,14759],{"class":414}," \u002Fetc\u002Fcontainerd\u002Fcerts.d\u002Fharbor.example.com\u002Fca.crt\n",[36,14761,14765],{"className":14762,"code":14763,"language":14764,"meta":44,"style":44},"language-toml shiki shiki-themes github-dark","# \u002Fetc\u002Fcontainerd\u002Fcerts.d\u002Fharbor.example.com\u002Fhosts.toml\nserver = \"https:\u002F\u002Fharbor.example.com\"\n\n[host.\"https:\u002F\u002Fharbor.example.com\"]\n  ca = \"\u002Fetc\u002Fcontainerd\u002Fcerts.d\u002Fharbor.example.com\u002Fca.crt\"\n","toml",[15,14766,14767,14772,14777,14781,14786],{"__ignoreMap":44},[376,14768,14769],{"class":378,"line":379},[376,14770,14771],{},"# \u002Fetc\u002Fcontainerd\u002Fcerts.d\u002Fharbor.example.com\u002Fhosts.toml\n",[376,14773,14774],{"class":378,"line":386},[376,14775,14776],{},"server = \"https:\u002F\u002Fharbor.example.com\"\n",[376,14778,14779],{"class":378,"line":397},[376,14780,1036],{"emptyLinePlaceholder":1035},[376,14782,14783],{"class":378,"line":405},[376,14784,14785],{},"[host.\"https:\u002F\u002Fharbor.example.com\"]\n",[376,14787,14788],{"class":378,"line":418},[376,14789,14790],{},"  ca = \"\u002Fetc\u002Fcontainerd\u002Fcerts.d\u002Fharbor.example.com\u002Fca.crt\"\n",[11,14792,14793,14794,14797],{},"測試環境如果只是暫時要略過驗證，可以加 ",[15,14795,14796],{},"--insecure-registry","，但正式環境不要這樣用。",[28,14799,14801],{"id":14800},"和-docker-不一樣的地方","和 Docker 不一樣的地方",[11,14803,14804],{},"nerdctl 雖然和 Docker CLI 相容，還是有幾個地方容易卡住：",[66,14806,14807,14836,14852,14861,14870],{},[69,14808,14809,14812,14813,14816,14817,14820,14821,14824,14825,14828,14829,14832,14833,4898],{},[23,14810,14811],{},"containerd 有 Namespace","：nerdctl 預設使用 ",[15,14814,14815],{},"default"," 這個 Namespace，而 Kubernetes 的 kubelet 使用 ",[15,14818,14819],{},"k8s.io","。在 K8s 節點上用 ",[15,14822,14823],{},"nerdctl pull"," 拉下來的映像檔，",[23,14826,14827],{},"kubelet 看不到","；要查看 K8s 建立的容器，也要加 ",[15,14830,14831],{},"--namespace k8s.io","（或 ",[15,14834,14835],{},"-n k8s.io",[69,14837,14838,14844,14845,6342,14848,14851],{},[23,14839,14840,14843],{},[15,14841,14842],{},"nerdctl build"," 需要 BuildKit","：nerdctl 本身不會建置映像檔，要另外安裝並啟動 ",[15,14846,14847],{},"buildkitd",[15,14849,14850],{},"nerdctl-full"," 版本有附）。這幾台主機只負責執行容器、建置交給 CI，所以沒有安裝",[69,14853,14854,55,14857,14860],{},[23,14855,14856],{},"沒有常駐程式也代表沒有自動重啟管理員",[15,14858,14859],{},"--restart=always"," 是由 nerdctl 搭配 containerd 的機制處理，主機重開機後要確認容器有自動起來",[69,14862,14863,14866,14867,14869],{},[23,14864,14865],{},"預設需要 root","：上面的指令都加了 ",[15,14868,13945],{},"。nerdctl 也支援 Rootless 模式，但需要額外設定",[69,14871,14872,14875,14876,14879,14880,14883,14884,14886],{},[23,14873,14874],{},"網卡名稱不同","：Docker 的預設橋接網卡是 ",[15,14877,14878],{},"docker0","，nerdctl 是 ",[15,14881,14882],{},"nerdctl0","，網段也不一樣。防火牆規則、監控設定如果有寫死 ",[15,14885,14878],{}," 要一起改",[28,14888,14890],{"id":14889},"錯誤紀錄移除-docker-後外部連不進容器","錯誤紀錄：移除 Docker 後外部連不進容器",[100,14892,1390],{"id":1390},[11,14894,14895,14896,14898],{},"主機原本裝過 Docker，移除後改用 containerd + nerdctl。",[15,14897,14368],{}," 啟動容器後：",[66,14900,14901,14910],{},[69,14902,14903,14904,14372,14907],{},"在主機上 ",[15,14905,14906],{},"curl localhost:8080",[23,14908,14909],{},"正常",[69,14911,14912,14913,14916,14917],{},"從外部其他機器連 ",[15,14914,14915],{},"http:\u002F\u002F主機IP:8080"," 卻",[23,14918,14919],{},"一直沒有回應，最後逾時",[11,14921,14922,14923,114],{},"相同的問題在 nerdctl 的 GitHub 也有人回報：",[119,14924,14927],{"href":14925,"rel":14926},"https:\u002F\u002Fgithub.com\u002Fcontainerd\u002Fnerdctl\u002Fdiscussions\u002F2142",[3057],"containerd\u002Fnerdctl#2142",[100,14929,11688],{"id":11688},[11,14931,14932],{},[1952,14933],{"alt":14934,"src":14935},"移除 Docker 後的 iptables 殘留示意圖：外部請求進入主機的 eth0，iptables nat 表的 PREROUTING 鏈中，Docker 留下的 DOCKER 鏈規則仍然存在並排在前面，指向已經不存在的 docker0 網段，封包被導到錯的地方而逾時；nerdctl 透過 CNI portmap 建立的 CNI-HOSTPORT-DNAT 規則排在後面，本應把 8080 轉到 nerdctl0 橋接網卡上的容器 10.4.0.2:80；從 localhost 連線走的是 OUTPUT 鏈，不受影響，所以本機正常、外部逾時；解法是刪除 Docker 殘留的 iptables 規則、鏈與 docker0 網卡","\u002Fimages\u002Farticles\u002Fcontainerd-iptables-leftover.svg",[11,14937,14938,14941,14942,14948,14949,14952,14953,14956,14957,2276,14960,14963,14964,14966,14967,14970],{},[15,14939,14940],{},"apt remove docker-ce"," 只會移除程式，",[23,14943,14944,14945,14947],{},"Docker 在系統上建立的 iptables 規則與 ",[15,14946,14878],{}," 網卡不會一起清掉","。Docker 會在 ",[15,14950,14951],{},"nat"," 表裡建立自己的 ",[15,14954,14955],{},"DOCKER"," 鏈，並在 ",[15,14958,14959],{},"PREROUTING",[15,14961,14962],{},"OUTPUT"," 中插入規則指向它；nerdctl 透過 CNI 的 ",[15,14965,14491],{}," 外掛建立的則是 ",[15,14968,14969],{},"CNI-HOSTPORT-DNAT"," 這組規則。",[11,14972,14973,14974,14976,14977,14980,14981,114],{},"外部連進來的封包會先經過 ",[15,14975,14959],{},"，Docker 殘留的規則仍然排在前面，干擾了原本應該由 CNI 處理的轉送；而從主機本身用 ",[15,14978,14979],{},"localhost"," 連線，走的是另一條路徑，所以本機測試一切正常。這也是這個問題很難發現的原因：",[23,14982,14983],{},"本機 curl 沒問題，只有從外面連才會出事",[100,14985,14986],{"id":14986},"解決辦法",[11,14988,14989],{},"清除 Docker 殘留的 iptables 規則與網卡。",[7381,14991,14992],{},[11,14993,14994],{},"⚠️ 修改 iptables 前，先確認自己不會把 SSH 連線一起擋掉，並且最好在有主控台可以登入的情況下操作。",[11,14996,14997],{},[23,14998,14999],{},"1. 先備份目前的規則",[36,15001,15003],{"className":1006,"code":15002,"language":1008,"meta":44,"style":44},"sudo iptables-save > ~\u002Fiptables-backup-$(date +%Y%m%d).rules\n",[15,15004,15005],{"__ignoreMap":44},[376,15006,15007,15009,15012,15014,15017,15019,15021,15024,15027],{"class":378,"line":379},[376,15008,13945],{"class":1020},[376,15010,15011],{"class":414}," iptables-save",[376,15013,14083],{"class":3196},[376,15015,15016],{"class":414}," ~\u002Fiptables-backup-",[376,15018,14289],{"class":393},[376,15020,9719],{"class":1020},[376,15022,15023],{"class":414}," +%Y%m%d",[376,15025,15026],{"class":393},")",[376,15028,15029],{"class":414},".rules\n",[11,15031,15032],{},[23,15033,15034],{},"2. 找出 Docker 相關的規則",[36,15036,15038],{"className":1006,"code":15037,"language":1008,"meta":44,"style":44},"# 列出 nat 表的規則與編號\nsudo iptables -t nat -L -n --line-numbers\n\n# 也可以直接篩選出和 Docker 有關的規則\nsudo iptables-save | grep -i docker\n",[15,15039,15040,15045,15067,15071,15076],{"__ignoreMap":44},[376,15041,15042],{"class":378,"line":379},[376,15043,15044],{"class":382},"# 列出 nat 表的規則與編號\n",[376,15046,15047,15049,15052,15055,15058,15061,15064],{"class":378,"line":386},[376,15048,13945],{"class":1020},[376,15050,15051],{"class":414}," iptables",[376,15053,15054],{"class":560}," -t",[376,15056,15057],{"class":414}," nat",[376,15059,15060],{"class":560}," -L",[376,15062,15063],{"class":560}," -n",[376,15065,15066],{"class":560}," --line-numbers\n",[376,15068,15069],{"class":378,"line":397},[376,15070,1036],{"emptyLinePlaceholder":1035},[376,15072,15073],{"class":378,"line":405},[376,15074,15075],{"class":382},"# 也可以直接篩選出和 Docker 有關的規則\n",[376,15077,15078,15080,15082,15084,15087,15090],{"class":378,"line":418},[376,15079,13945],{"class":1020},[376,15081,15011],{"class":414},[376,15083,14067],{"class":3196},[376,15085,15086],{"class":1020}," grep",[376,15088,15089],{"class":560}," -i",[376,15091,15092],{"class":414}," docker\n",[11,15094,15095],{},[23,15096,15097],{},"3. 刪除規則",[11,15099,15100,15101,2276,15103,2276,15105,15108,15109,15111,15112,15114],{},"先刪除 ",[15,15102,14959],{},[15,15104,14962],{},[15,15106,15107],{},"POSTROUTING"," 中指向 ",[15,15110,14955],{}," 的規則（用上一步看到的編號），再清空並刪除 ",[15,15113,14955],{}," 鏈本身：",[36,15116,15118],{"className":1006,"code":15117,"language":1008,"meta":44,"style":44},"# 刪除指定編號的規則，例如 PREROUTING 的第 1 條（編號依實際情況）\nsudo iptables -t nat -D PREROUTING 1\n\n# 清空 DOCKER 鏈內的規則\nsudo iptables -t nat -F DOCKER\n\n# 沒有任何規則再引用它之後，才能刪除這條鏈\nsudo iptables -t nat -X DOCKER\n",[15,15119,15120,15125,15144,15148,15153,15169,15173,15178],{"__ignoreMap":44},[376,15121,15122],{"class":378,"line":379},[376,15123,15124],{"class":382},"# 刪除指定編號的規則，例如 PREROUTING 的第 1 條（編號依實際情況）\n",[376,15126,15127,15129,15131,15133,15135,15138,15141],{"class":378,"line":386},[376,15128,13945],{"class":1020},[376,15130,15051],{"class":414},[376,15132,15054],{"class":560},[376,15134,15057],{"class":414},[376,15136,15137],{"class":560}," -D",[376,15139,15140],{"class":414}," PREROUTING",[376,15142,15143],{"class":560}," 1\n",[376,15145,15146],{"class":378,"line":397},[376,15147,1036],{"emptyLinePlaceholder":1035},[376,15149,15150],{"class":378,"line":405},[376,15151,15152],{"class":382},"# 清空 DOCKER 鏈內的規則\n",[376,15154,15155,15157,15159,15161,15163,15166],{"class":378,"line":418},[376,15156,13945],{"class":1020},[376,15158,15051],{"class":414},[376,15160,15054],{"class":560},[376,15162,15057],{"class":414},[376,15164,15165],{"class":560}," -F",[376,15167,15168],{"class":414}," DOCKER\n",[376,15170,15171],{"class":378,"line":429},[376,15172,1036],{"emptyLinePlaceholder":1035},[376,15174,15175],{"class":378,"line":437},[376,15176,15177],{"class":382},"# 沒有任何規則再引用它之後，才能刪除這條鏈\n",[376,15179,15180,15182,15184,15186,15188,15191],{"class":378,"line":447},[376,15181,13945],{"class":1020},[376,15183,15051],{"class":414},[376,15185,15054],{"class":560},[376,15187,15057],{"class":414},[376,15189,15190],{"class":560}," -X",[376,15192,15168],{"class":414},[11,15194,15195,15196,15199,15200,5226,15205,15208,15209,2276,15211,2276,15214,15217],{},"刪除一條規則後，後面規則的編號會往前移，所以",[23,15197,15198],{},"每刪一條就重新列一次","，或是從編號大的開始刪，避免刪錯（參考 ",[119,15201,15204],{"href":15202,"rel":15203},"https:\u002F\u002Fbobcares.com\u002Fblog\u002Fiptables-index-of-deletion-too-big\u002F",[3057],"iptables: Index of deletion too big",[15,15206,15207],{},"filter"," 表中的 ",[15,15210,14955],{},[15,15212,15213],{},"DOCKER-USER",[15,15215,15216],{},"DOCKER-ISOLATION-STAGE-1\u002F2"," 等鏈也用同樣的方式處理。",[11,15219,15220],{},[23,15221,15222],{},"4. 刪除 docker0 網卡",[36,15224,15226],{"className":1006,"code":15225,"language":1008,"meta":44,"style":44},"ip link show                     # 找到 docker0\nsudo ip link set docker0 down\nsudo ip link delete docker0\n",[15,15227,15228,15242,15260],{"__ignoreMap":44},[376,15229,15230,15233,15236,15239],{"class":378,"line":379},[376,15231,15232],{"class":1020},"ip",[376,15234,15235],{"class":414}," link",[376,15237,15238],{"class":414}," show",[376,15240,15241],{"class":382},"                     # 找到 docker0\n",[376,15243,15244,15246,15249,15251,15254,15257],{"class":378,"line":386},[376,15245,13945],{"class":1020},[376,15247,15248],{"class":414}," ip",[376,15250,15235],{"class":414},[376,15252,15253],{"class":414}," set",[376,15255,15256],{"class":414}," docker0",[376,15258,15259],{"class":414}," down\n",[376,15261,15262,15264,15266,15268,15271],{"class":378,"line":397},[376,15263,13945],{"class":1020},[376,15265,15248],{"class":414},[376,15267,15235],{"class":414},[376,15269,15270],{"class":414}," delete",[376,15272,15273],{"class":414}," docker0\n",[11,15275,15276,15277,15280,15281,15284,15285,15287,15288,15293],{},"（舊的做法是 ",[15,15278,15279],{},"ifconfig docker0 down","，但 ",[15,15282,15283],{},"ifconfig"," 在新版的 Ubuntu 已經不是預設安裝，建議改用 ",[15,15286,15232],{}," 指令；參考 ",[119,15289,15292],{"href":15290,"rel":15291},"https:\u002F\u002Fstackoverflow.com\u002Fquestions\u002F34326704\u002Fhow-do-i-delete-virtual-interface-in-linux",[3057],"How do I delete virtual interface in Linux","。）",[11,15295,15296],{},[23,15297,15298],{},"5. 重新建立容器並從外部驗證",[36,15300,15302],{"className":1006,"code":15301,"language":1008,"meta":44,"style":44},"sudo nerdctl rm -f web\nsudo nerdctl run -d --name web -p 8080:80 nginx:alpine\n\n# 確認 CNI 的 Port 對應規則存在\nsudo iptables -t nat -L CNI-HOSTPORT-DNAT -n\n\n# 從另一台機器測試\ncurl http:\u002F\u002F\u003C主機IP>:8080\n",[15,15303,15304,15318,15338,15342,15347,15365,15369,15374],{"__ignoreMap":44},[376,15305,15306,15308,15310,15313,15315],{"class":378,"line":379},[376,15307,13945],{"class":1020},[376,15309,14641],{"class":414},[376,15311,15312],{"class":414}," rm",[376,15314,6926],{"class":560},[376,15316,15317],{"class":414}," web\n",[376,15319,15320,15322,15324,15326,15328,15330,15332,15334,15336],{"class":378,"line":386},[376,15321,13945],{"class":1020},[376,15323,14641],{"class":414},[376,15325,14644],{"class":414},[376,15327,13981],{"class":560},[376,15329,14649],{"class":560},[376,15331,14652],{"class":414},[376,15333,14148],{"class":560},[376,15335,14657],{"class":414},[376,15337,14660],{"class":414},[376,15339,15340],{"class":378,"line":397},[376,15341,1036],{"emptyLinePlaceholder":1035},[376,15343,15344],{"class":378,"line":405},[376,15345,15346],{"class":382},"# 確認 CNI 的 Port 對應規則存在\n",[376,15348,15349,15351,15353,15355,15357,15359,15362],{"class":378,"line":418},[376,15350,13945],{"class":1020},[376,15352,15051],{"class":414},[376,15354,15054],{"class":560},[376,15356,15057],{"class":414},[376,15358,15060],{"class":560},[376,15360,15361],{"class":414}," CNI-HOSTPORT-DNAT",[376,15363,15364],{"class":560}," -n\n",[376,15366,15367],{"class":378,"line":429},[376,15368,1036],{"emptyLinePlaceholder":1035},[376,15370,15371],{"class":378,"line":437},[376,15372,15373],{"class":382},"# 從另一台機器測試\n",[376,15375,15376,15378,15381,15383,15386,15389,15392],{"class":378,"line":447},[376,15377,14674],{"class":1020},[376,15379,15380],{"class":414}," http:\u002F\u002F",[376,15382,3176],{"class":3196},[376,15384,15385],{"class":414},"主機I",[376,15387,15388],{"class":393},"P",[376,15390,15391],{"class":3196},">",[376,15393,15394],{"class":414},":8080\n",[11,15396,15397,15398,15401,15402,15405],{},"如果主機有用 ",[15,15399,15400],{},"iptables-persistent"," 之類的工具把規則存起來，開機時會自動載回來，記得清理後重新儲存（",[15,15403,15404],{},"sudo netfilter-persistent save","），不然重開機問題又回來了。",[100,15407,15408],{"id":15408},"避免再發生",[66,15410,15411,15417,15423],{},[69,15412,15413,15416],{},[23,15414,15415],{},"新主機直接裝 containerd + nerdctl","，不要先裝 Docker 再移除",[69,15418,15419,15420,15422],{},"舊主機要換掉 Docker 時，把「清理 iptables 與 ",[15,15421,14878],{},"」寫進移轉步驟，而不是等出問題才處理",[69,15424,15425,15426,15429,15430],{},"驗收時",[23,15427,15428],{},"一定要從外部機器測試","，不能只在主機上 ",[15,15431,15432],{},"curl localhost",[28,15434,15435],{"id":15435},"參考資料",[66,15437,15438,15445,15452],{},[69,15439,15440],{},[119,15441,15444],{"href":15442,"rel":15443},"https:\u002F\u002Fgithub.com\u002Fcontainerd\u002Fcontainerd\u002Fblob\u002Fmain\u002Fdocs\u002Fgetting-started.md",[3057],"containerd Getting Started",[69,15446,15447],{},[119,15448,15451],{"href":15449,"rel":15450},"https:\u002F\u002Fdocs.docker.com\u002Fengine\u002Finstall\u002Fubuntu\u002F",[3057],"Install Docker Engine on Ubuntu（containerd.io 套件來源）",[69,15453,15454],{},[119,15455,15458],{"href":15456,"rel":15457},"https:\u002F\u002Fwww.qikqiak.com\u002Fk8strain2\u002Fcontainerd\u002Fnerdctl\u002F",[3057],"nerdctl 安裝說明",[28,15460,1890],{"id":1890},[11,15462,15463,15464,15466],{},"Docker 底層本來就是 containerd，拿掉 dockerd 之後，只要自己補上建立容器的 runc 與負責網路的 CNI plugins，再用和 Docker 指令相容的 nerdctl 操作，就能用更少的元件跑一樣的容器；但從 Docker 換過來的主機，記得清掉 Docker 殘留的 iptables 規則與 ",[15,15465,14878],{},"，而且驗收時一定要從外部連線測試。",[1132,15468,15469],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":44,"searchDepth":386,"depth":386,"links":15471},[15472,15473,15474,15475,15480,15481,15482,15485,15489,15490,15496,15497],{"id":13681,"depth":386,"text":13682},{"id":13713,"depth":386,"text":13714},{"id":13833,"depth":386,"text":13834},{"id":13923,"depth":386,"text":13924,"children":15476},[15477,15478,15479],{"id":13927,"depth":397,"text":13928},{"id":14089,"depth":397,"text":13924},{"id":14128,"depth":397,"text":14128},{"id":14216,"depth":386,"text":14217},{"id":14361,"depth":386,"text":14362},{"id":14499,"depth":386,"text":14500,"children":15483},[15484],{"id":14629,"depth":397,"text":14629},{"id":14680,"depth":386,"text":14681,"children":15486},[15487,15488],{"id":14684,"depth":397,"text":14684},{"id":14719,"depth":397,"text":14719},{"id":14800,"depth":386,"text":14801},{"id":14889,"depth":386,"text":14890,"children":15491},[15492,15493,15494,15495],{"id":1390,"depth":397,"text":1390},{"id":11688,"depth":397,"text":11688},{"id":14986,"depth":397,"text":14986},{"id":15408,"depth":397,"text":15408},{"id":15435,"depth":386,"text":15435},{"id":1890,"depth":386,"text":1890},"2024-06-18",{},"\u002Farticles\u002Fcontainerd-and-nerdctl",{"title":13676,"description":44},"articles\u002Fcontainerd-and-nerdctl",[13705,13709,15504,15505,1454],"容器","Linux","KaKC42KosE0tE9tml1JzT3OhkEa9Q15MO3Em9smbTVE",{"id":15508,"title":15509,"body":15510,"date":16278,"description":44,"excerpt":1156,"extension":1157,"meta":16279,"navigation":1035,"path":16280,"seo":16281,"stem":16282,"tags":16283,"__hash__":16288},"articles\u002Farticles\u002Fterraform-remote-state-modules.md","Terraform 團隊協作的兩個基本功：S3 + DynamoDB 遠端 State，以及可重複使用的模組設計",{"type":8,"value":15511,"toc":16267},[15512,15516,15529,15539,15558,15569,15573,15576,15602,15606,15612,15618,15705,15750,15754,15764,15768,15775,15778,15784,15791,15795,15801,15920,15923,15928,16061,16067,16072,16126,16133,16138,16144,16149,16210,16217,16222,16233,16237,16240,16259,16261,16264],[28,15513,15515],{"id":15514},"先搞懂-state-是什麼","先搞懂 State 是什麼",[11,15517,15518,15519,15522,15523,6342,15526,5226],{},"Terraform 是宣告式的：你描述「想要有一台 EC2、一個 S3 Bucket」，Terraform 負責把現實變成你描述的樣子。要做到這點，它需要知道「",[23,15520,15521],{},"現在已經建立了哪些資源","」，這份紀錄就是 ",[23,15524,15525],{},"State",[15,15527,15528],{},"terraform.tfstate",[11,15530,15531,15532,2390,15535,15538],{},"每次執行 ",[15,15533,15534],{},"plan",[15,15536,15537],{},"apply","，Terraform 會比對三個東西：",[211,15540,15541,15547,15552],{},[69,15542,15543,15546],{},[23,15544,15545],{},"程式碼","：你想要的狀態",[69,15548,15549,15551],{},[23,15550,15525],{},"：Terraform 記得它上次建立了什麼，以及每個資源在雲端上的 ID",[69,15553,15554,15557],{},[23,15555,15556],{},"雲端實際狀態","：透過 API 查詢資源目前真正的樣子",[11,15559,15560,15561,15564,15565,15568],{},"State 是 Terraform 認得「程式碼裡的這個 ",[15,15562,15563],{},"aws_s3_bucket.logs","，就是雲端上那個 Bucket」的唯一依據。",[23,15566,15567],{},"State 遺失了，Terraform 就不認得任何既有的資源","，下一次 apply 會想要重新建立一份；State 損壞了，可能會刪掉不該刪的東西。",[28,15570,15572],{"id":15571},"本機-state-的問題","本機 State 的問題",[11,15574,15575],{},"預設情況下，State 存在執行 Terraform 的那台電腦上：",[66,15577,15578,15584,15590,15596],{},[69,15579,15580,15583],{},[23,15581,15582],{},"只有一個人有","：同事要修改基礎設施，得先跟你拿 State 檔",[69,15585,15586,15589],{},[23,15587,15588],{},"沒有鎖","：兩個人同時 apply，兩邊都讀到舊的 State、各自改了資源、各自寫回 State，後寫的蓋掉先寫的，State 和雲端實際狀態對不起來",[69,15591,15592,15595],{},[23,15593,15594],{},"含有機密","：State 會以明文保存資源的屬性，資料庫密碼、存取金鑰都可能出現在裡面，放在個人電腦上或 commit 進 Git 都很危險",[69,15597,15598,15601],{},[23,15599,15600],{},"CI 無法執行","：Pipeline 拿不到 State，就無法自動化 apply",[28,15603,15605],{"id":15604},"遠端後端s3-存-statedynamodb-做鎖","遠端後端：S3 存 State、DynamoDB 做鎖",[11,15607,15608,15609,55],{},"AWS 上最常見的遠端後端組合，是 ",[23,15610,15611],{},"S3 存放 State 檔、DynamoDB 做狀態鎖",[11,15613,15614],{},[1952,15615],{"alt":15616,"src":15617},"Terraform 遠端 State 示意圖：工程師 A 對 production 執行 apply，先在 DynamoDB 取得該 State 的鎖；工程師 B 同時對 production apply 時，因為鎖被佔用而等待或失敗；CI Pipeline 對 development apply，取得的是另一份 State 的鎖，互不影響；持有鎖才能讀寫 S3 上的 State 檔，S3 開啟版本控制、加密並封鎖公開存取；production 與 development 使用同一組模組但不同的變數值，State 完全隔離，在 dev 下的指令不可能動到 prod","\u002Fimages\u002Farticles\u002Fterraform-remote-state.svg",[36,15619,15623],{"className":15620,"code":15621,"language":15622,"meta":44,"style":44},"language-hcl shiki shiki-themes github-dark","terraform {\n  backend \"s3\" {\n    bucket         = \"company-terraform-backend\"\n    key            = \"production\u002Fapp\u002Fterraform.tfstate\"\n    region         = \"ap-northeast-1\"\n    dynamodb_table = \"company-terraform-lock\"\n    encrypt        = true\n  }\n}\n","hcl",[15,15624,15625,15633,15643,15654,15665,15675,15685,15696,15701],{"__ignoreMap":44},[376,15626,15627,15630],{"class":378,"line":379},[376,15628,15629],{"class":1020},"terraform",[376,15631,15632],{"class":393}," {\n",[376,15634,15635,15638,15641],{"class":378,"line":386},[376,15636,15637],{"class":1020},"  backend",[376,15639,15640],{"class":560}," \"s3\"",[376,15642,15632],{"class":393},[376,15644,15645,15648,15651],{"class":378,"line":397},[376,15646,15647],{"class":393},"    bucket",[376,15649,15650],{"class":3196},"         =",[376,15652,15653],{"class":414}," \"company-terraform-backend\"\n",[376,15655,15656,15659,15662],{"class":378,"line":405},[376,15657,15658],{"class":393},"    key",[376,15660,15661],{"class":3196},"            =",[376,15663,15664],{"class":414}," \"production\u002Fapp\u002Fterraform.tfstate\"\n",[376,15666,15667,15670,15672],{"class":378,"line":418},[376,15668,15669],{"class":393},"    region",[376,15671,15650],{"class":3196},[376,15673,15674],{"class":414}," \"ap-northeast-1\"\n",[376,15676,15677,15680,15682],{"class":378,"line":429},[376,15678,15679],{"class":393},"    dynamodb_table",[376,15681,3313],{"class":3196},[376,15683,15684],{"class":414}," \"company-terraform-lock\"\n",[376,15686,15687,15690,15693],{"class":378,"line":437},[376,15688,15689],{"class":393},"    encrypt",[376,15691,15692],{"class":3196},"        =",[376,15694,15695],{"class":560}," true\n",[376,15697,15698],{"class":378,"line":447},[376,15699,15700],{"class":393},"  }\n",[376,15702,15703],{"class":378,"line":531},[376,15704,3381],{"class":393},[66,15706,15707,15724,15736],{},[69,15708,15709,15712,15713,15716,15717,15720,15721],{},[23,15710,15711],{},"S3 Bucket","：存放 State。務必開啟",[23,15714,15715],{},"版本控制","（State 被寫壞時可以回到上一版）、",[23,15718,15719],{},"伺服器端加密","，並",[23,15722,15723],{},"封鎖所有公開存取",[69,15725,15726,2378,15729,15732,15733,15735],{},[23,15727,15728],{},"DynamoDB 資料表",[15,15730,15731],{},"LockID","（字串）為主鍵。執行 ",[15,15734,15537],{}," 前，Terraform 會在表中寫入一筆以 State 路徑為鍵的紀錄，代表「我正在使用這份 State」；其他人此時執行，會看到鎖被佔用而等待或失敗；執行結束後自動釋放",[69,15737,15738,15741,15742,15745,15746,15749],{},[23,15739,15740],{},"存取權限","：用 IAM 限制誰能讀寫哪些 State 路徑，例如開發人員只能操作 ",[15,15743,15744],{},"development\u002F"," 下的 State，",[15,15747,15748],{},"production\u002F"," 只有部署用的 Role 可以寫入",[100,15751,15753],{"id":15752},"雞生蛋的問題誰來建立後端","雞生蛋的問題：誰來建立後端？",[11,15755,15756,15757,15760,15761,5226],{},"存放 State 的 Bucket 和鎖表本身也是 AWS 資源，也想用 Terraform 管理，但它們必須在「其他所有 Terraform 專案之前」就存在。常見的做法是做一個獨立的 ",[23,15758,15759],{},"Bootstrap"," 專案：先用本機 State 建立 Bucket 與鎖表，建好之後再把這個專案自己的 State 也遷移到剛建立的遠端後端（",[15,15762,15763],{},"terraform init -migrate-state",[28,15765,15767],{"id":15766},"環境隔離每個環境一份-state","環境隔離：每個環境一份 State",[11,15769,15770,15771,15774],{},"開發、測試、正式環境",[23,15772,15773],{},"一定要使用不同的 State","。如果所有環境共用一份 State，一次在開發環境的誤操作，就可能動到正式環境的資源。",[11,15776,15777],{},"常見的目錄結構：",[36,15779,15782],{"className":15780,"code":15781,"language":41,"meta":44},[39],"infrastructure\u002F\n├── global\u002F          ← 只需要一份的共用資源：State 後端、IAM Role\n├── development\u002F\n│   └── main.tf      ← backend key = development\u002F…\u002Fterraform.tfstate\n├── stage\u002F\n└── production\u002F\n    └── main.tf      ← backend key = production\u002F…\u002Fterraform.tfstate\nmodules\u002F\n└── aws\u002F\n    ├── alb\u002F\n    ├── ec2\u002F\n    └── s3\u002F\n",[15,15783,15781],{"__ignoreMap":44},[11,15785,15786,15787,15790],{},"每個環境的目錄有自己的後端設定與 State，彼此完全隔離；資源的寫法則抽成共用的",[23,15788,15789],{},"模組","，環境之間的差異只在傳入的變數值。",[28,15792,15794],{"id":15793},"模組設計讓每個環境都用同一份寫法","模組設計：讓每個環境都用同一份寫法",[11,15796,15797,15798,15800],{},"如果每個環境各自複製一份 ALB、EC2 的寫法，一開始一模一樣，幾個月後就會各自長出不同的修改，誰也說不清楚正式環境和測試環境到底差在哪。",[23,15799,15789],{},"讓「怎麼建立一個 ALB」只寫一次：",[36,15802,15804],{"className":15620,"code":15803,"language":15622,"meta":44,"style":44},"# production\u002Fmain.tf\nmodule \"web_alb\" {\n  source = \"..\u002F..\u002Fmodules\u002Faws\u002Falb\"\n\n  name              = \"web-prod\"\n  vpc_id            = data.aws_vpc.main.id\n  subnet_ids        = data.aws_subnets.public.ids\n  certificate_arn   = var.certificate_arn\n  enable_access_log = true\n}\n",[15,15805,15806,15811,15821,15830,15834,15844,15869,15892,15907,15916],{"__ignoreMap":44},[376,15807,15808],{"class":378,"line":379},[376,15809,15810],{"class":382},"# production\u002Fmain.tf\n",[376,15812,15813,15816,15819],{"class":378,"line":386},[376,15814,15815],{"class":1020},"module",[376,15817,15818],{"class":560}," \"web_alb\"",[376,15820,15632],{"class":393},[376,15822,15823,15825,15827],{"class":378,"line":397},[376,15824,7126],{"class":393},[376,15826,3313],{"class":3196},[376,15828,15829],{"class":414}," \"..\u002F..\u002Fmodules\u002Faws\u002Falb\"\n",[376,15831,15832],{"class":378,"line":405},[376,15833,1036],{"emptyLinePlaceholder":1035},[376,15835,15836,15838,15841],{"class":378,"line":418},[376,15837,499],{"class":393},[376,15839,15840],{"class":3196},"              =",[376,15842,15843],{"class":414}," \"web-prod\"\n",[376,15845,15846,15849,15851,15854,15856,15859,15861,15864,15866],{"class":378,"line":429},[376,15847,15848],{"class":393},"  vpc_id",[376,15850,15661],{"class":3196},[376,15852,15853],{"class":393}," data",[376,15855,4735],{"class":3196},[376,15857,15858],{"class":393},"aws_vpc",[376,15860,4735],{"class":3196},[376,15862,15863],{"class":393},"main",[376,15865,4735],{"class":3196},[376,15867,15868],{"class":393},"id\n",[376,15870,15871,15874,15876,15878,15880,15883,15885,15887,15889],{"class":378,"line":437},[376,15872,15873],{"class":393},"  subnet_ids",[376,15875,15692],{"class":3196},[376,15877,15853],{"class":393},[376,15879,4735],{"class":3196},[376,15881,15882],{"class":393},"aws_subnets",[376,15884,4735],{"class":3196},[376,15886,3274],{"class":393},[376,15888,4735],{"class":3196},[376,15890,15891],{"class":393},"ids\n",[376,15893,15894,15897,15900,15902,15904],{"class":378,"line":447},[376,15895,15896],{"class":393},"  certificate_arn",[376,15898,15899],{"class":3196},"   =",[376,15901,9422],{"class":393},[376,15903,4735],{"class":3196},[376,15905,15906],{"class":393},"certificate_arn\n",[376,15908,15909,15912,15914],{"class":378,"line":531},[376,15910,15911],{"class":393},"  enable_access_log",[376,15913,3313],{"class":3196},[376,15915,15695],{"class":560},[376,15917,15918],{"class":378,"line":542},[376,15919,3381],{"class":393},[11,15921,15922],{},"一個好維護的模組，通常會有這幾個特徵：",[11,15924,15925],{},[23,15926,15927],{},"輸入有型別、有驗證、有合理的預設值",[36,15929,15931],{"className":15620,"code":15930,"language":15622,"meta":44,"style":44},"variable \"name\" {\n  description = \"ALB 名稱，會作為相關資源的名稱前綴\"\n  type        = string\n\n  validation {\n    condition     = length(var.name) \u003C= 28\n    error_message = \"name 長度不可超過 28 個字元（ALB 名稱上限為 32）。\"\n  }\n}\n\nvariable \"enable_access_log\" {\n  description = \"是否開啟存取日誌\"\n  type        = bool\n  default     = false\n}\n",[15,15932,15933,15943,15953,15963,15967,15974,15998,16008,16012,16016,16020,16029,16038,16047,16057],{"__ignoreMap":44},[376,15934,15935,15938,15941],{"class":378,"line":379},[376,15936,15937],{"class":1020},"variable",[376,15939,15940],{"class":560}," \"name\"",[376,15942,15632],{"class":393},[376,15944,15945,15948,15950],{"class":378,"line":386},[376,15946,15947],{"class":393},"  description",[376,15949,3313],{"class":3196},[376,15951,15952],{"class":414}," \"ALB 名稱，會作為相關資源的名稱前綴\"\n",[376,15954,15955,15958,15960],{"class":378,"line":397},[376,15956,15957],{"class":393},"  type",[376,15959,15692],{"class":3196},[376,15961,15962],{"class":3196}," string\n",[376,15964,15965],{"class":378,"line":405},[376,15966,1036],{"emptyLinePlaceholder":1035},[376,15968,15969,15972],{"class":378,"line":418},[376,15970,15971],{"class":1020},"  validation",[376,15973,15632],{"class":393},[376,15975,15976,15979,15982,15985,15988,15990,15993,15995],{"class":378,"line":429},[376,15977,15978],{"class":393},"    condition",[376,15980,15981],{"class":3196},"     =",[376,15983,15984],{"class":560}," length",[376,15986,15987],{"class":393},"(var",[376,15989,4735],{"class":3196},[376,15991,15992],{"class":393},"name) ",[376,15994,13150],{"class":3196},[376,15996,15997],{"class":560}," 28\n",[376,15999,16000,16003,16005],{"class":378,"line":437},[376,16001,16002],{"class":393},"    error_message",[376,16004,3313],{"class":3196},[376,16006,16007],{"class":414}," \"name 長度不可超過 28 個字元（ALB 名稱上限為 32）。\"\n",[376,16009,16010],{"class":378,"line":447},[376,16011,15700],{"class":393},[376,16013,16014],{"class":378,"line":531},[376,16015,3381],{"class":393},[376,16017,16018],{"class":378,"line":542},[376,16019,1036],{"emptyLinePlaceholder":1035},[376,16021,16022,16024,16027],{"class":378,"line":552},[376,16023,15937],{"class":1020},[376,16025,16026],{"class":560}," \"enable_access_log\"",[376,16028,15632],{"class":393},[376,16030,16031,16033,16035],{"class":378,"line":564},[376,16032,15947],{"class":393},[376,16034,3313],{"class":3196},[376,16036,16037],{"class":414}," \"是否開啟存取日誌\"\n",[376,16039,16040,16042,16044],{"class":378,"line":575},[376,16041,15957],{"class":393},[376,16043,15692],{"class":3196},[376,16045,16046],{"class":3196}," bool\n",[376,16048,16049,16052,16054],{"class":378,"line":583},[376,16050,16051],{"class":393},"  default",[376,16053,15981],{"class":3196},[376,16055,16056],{"class":560}," false\n",[376,16058,16059],{"class":378,"line":597},[376,16060,3381],{"class":393},[11,16062,16063,16064,16066],{},"錯誤的參數在 ",[15,16065,15534],{}," 階段就被擋下來，而不是 apply 到一半才收到 AWS API 的錯誤。",[11,16068,16069],{},[23,16070,16071],{},"輸出其他模組需要的值",[36,16073,16075],{"className":15620,"code":16074,"language":15622,"meta":44,"style":44},"output \"arn\"      { value = aws_lb.this.arn }\noutput \"dns_name\" { value = aws_lb.this.dns_name }\n",[15,16076,16077,16103],{"__ignoreMap":44},[376,16078,16079,16082,16085,16088,16090,16093,16095,16098,16100],{"class":378,"line":379},[376,16080,16081],{"class":1020},"output",[376,16083,16084],{"class":560}," \"arn\"",[376,16086,16087],{"class":393},"      { value",[376,16089,3313],{"class":3196},[376,16091,16092],{"class":393}," aws_lb",[376,16094,4735],{"class":3196},[376,16096,16097],{"class":393},"this",[376,16099,4735],{"class":3196},[376,16101,16102],{"class":393},"arn }\n",[376,16104,16105,16107,16110,16113,16115,16117,16119,16121,16123],{"class":378,"line":386},[376,16106,16081],{"class":1020},[376,16108,16109],{"class":560}," \"dns_name\"",[376,16111,16112],{"class":393}," { value",[376,16114,3313],{"class":3196},[376,16116,16092],{"class":393},[376,16118,4735],{"class":3196},[376,16120,16097],{"class":393},[376,16122,4735],{"class":3196},[376,16124,16125],{"class":393},"dns_name }\n",[11,16127,16128,16129,16132],{},"模組之間透過輸出值串接，例如 CloudFront 模組需要 ALB 的 ",[15,16130,16131],{},"dns_name"," 作為來源。",[11,16134,16135],{},[23,16136,16137],{},"單一職責、適當的抽象層級",[11,16139,16140,16141,16143],{},"模組應該代表一個有意義的元件（一個 ALB 加上它的監聽器與安全群組），而不是把整個環境塞進一個模組，也不要包裝到只剩一個 ",[15,16142,9323],{}," 的薄殼——那樣只是多了一層間接，沒有帶來任何價值。",[11,16145,16146],{},[23,16147,16148],{},"鎖定版本",[36,16150,16152],{"className":15620,"code":16151,"language":15622,"meta":44,"style":44},"terraform {\n  required_version = \">= 1.5\"\n  required_providers {\n    aws = { source = \"hashicorp\u002Faws\", version = \"~> 5.0\" }\n  }\n}\n",[15,16153,16154,16160,16170,16177,16202,16206],{"__ignoreMap":44},[376,16155,16156,16158],{"class":378,"line":379},[376,16157,15629],{"class":1020},[376,16159,15632],{"class":393},[376,16161,16162,16165,16167],{"class":378,"line":386},[376,16163,16164],{"class":393},"  required_version",[376,16166,3313],{"class":3196},[376,16168,16169],{"class":414}," \">= 1.5\"\n",[376,16171,16172,16175],{"class":378,"line":397},[376,16173,16174],{"class":1020},"  required_providers",[376,16176,15632],{"class":393},[376,16178,16179,16182,16184,16187,16189,16192,16195,16197,16200],{"class":378,"line":405},[376,16180,16181],{"class":393},"    aws",[376,16183,3313],{"class":3196},[376,16185,16186],{"class":393}," { source ",[376,16188,3240],{"class":3196},[376,16190,16191],{"class":414}," \"hashicorp\u002Faws\"",[376,16193,16194],{"class":393},", version ",[376,16196,3240],{"class":3196},[376,16198,16199],{"class":414}," \"~> 5.0\"",[376,16201,3610],{"class":393},[376,16203,16204],{"class":378,"line":418},[376,16205,15700],{"class":393},[376,16207,16208],{"class":378,"line":429},[376,16209,3381],{"class":393},[11,16211,16212,16213,16216],{},"模組放在獨立的 Git Repo 時，使用端以 ",[15,16214,16215],{},"?ref=v1.2.0"," 指定版本，模組升級不會意外影響所有環境。",[11,16218,16219],{},[23,16220,16221],{},"文件與測試",[11,16223,16224,16225,16228,16229,16232],{},"每個模組附上說明輸入、輸出、使用範例的 README（可以用 ",[15,16226,16227],{},"terraform-docs"," 自動產生），並用 ",[23,16230,16231],{},"Terratest","（Go）撰寫整合測試：真的在測試帳號中建立資源、驗證行為、再自動刪除，確保模組修改後仍然能正常運作。",[28,16234,16236],{"id":16235},"使用情境建立公司的-terraform-規範","使用情境：建立公司的 Terraform 規範",[11,16238,16239],{},"在房仲公司導入 Terraform 時，主要的工作就是上面這兩件事：",[66,16241,16242,16249,16256],{},[69,16243,16244,16245,16248],{},"以 Bootstrap 專案建立 ",[23,16246,16247],{},"S3 + DynamoDB"," 的遠端後端與 IAM Role，所有專案統一使用，State 不再散落在個人電腦上",[69,16250,16251,16252,16255],{},"依環境切分目錄，",[23,16253,16254],{},"每個環境一份 State","，搭配 IAM 權限控制誰能操作正式環境",[69,16257,16258],{},"把 ELB、CloudFront、EC2、S3、EKS 等常用資源做成模組，附上文件與 Terratest 整合測試，各團隊以一致的方式建立基礎設施",[28,16260,1890],{"id":1890},[11,16262,16263],{},"State 是 Terraform 認得雲端資源的唯一依據，團隊協作時要放在 S3 這類遠端後端，並用 DynamoDB 加鎖避免同時 apply，再開啟版本控制、加密與權限控管；每個環境各自一份 State 確保互不影響，資源的寫法則抽成有型別驗證、有輸出、有版本、有文件和測試的模組，讓所有環境都用同一份程式碼建立。",[1132,16265,16266],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":44,"searchDepth":386,"depth":386,"links":16268},[16269,16270,16271,16274,16275,16276,16277],{"id":15514,"depth":386,"text":15515},{"id":15571,"depth":386,"text":15572},{"id":15604,"depth":386,"text":15605,"children":16272},[16273],{"id":15752,"depth":397,"text":15753},{"id":15766,"depth":386,"text":15767},{"id":15793,"depth":386,"text":15794},{"id":16235,"depth":386,"text":16236},{"id":1890,"depth":386,"text":1890},"2024-05-21",{},"\u002Farticles\u002Fterraform-remote-state-modules",{"title":15509,"description":44},"articles\u002Fterraform-remote-state-modules",[16284,16285,16286,1454,16287],"Terraform","IaC","AWS","基礎設施","_mv9kWzgByleELRNTBGbmb7uMghhEmV3h7mi2fhcZ1Y",{"id":16290,"title":16291,"body":16292,"date":16886,"description":16887,"excerpt":1156,"extension":1157,"meta":16888,"navigation":1035,"path":8501,"seo":16889,"stem":16890,"tags":16891,"__hash__":16893},"articles\u002Farticles\u002Fddd-subdomains-bounded-context.md","怎麼劃分 Domain？找出子域、分辨核心／支撐／通用，再對應到限界上下文",{"type":8,"value":16293,"toc":16869},[16294,16302,16322,16325,16385,16391,16395,16398,16402,16405,16428,16432,16439,16444,16447,16451,16454,16462,16466,16480,16486,16490,16496,16546,16550,16557,16571,16577,16580,16606,16613,16616,16630,16633,16637,16644,16664,16670,16677,16680,16692,16695,16698,16724,16727,16772,16776,16783,16861,16864,16866],[11,16295,16296,16298,16299,16301],{},[119,16297,8738],{"href":8737}," 裡簡單介紹了子域和限界上下文，",[119,16300,11082],{"href":11081}," 那篇也從故事裡找出了「搜尋、帶看、斡旋」三個候選邊界。但真的要替整個系統劃分時，還有幾個問題要回答：",[211,16303,16304,16310,16316],{},[69,16305,16306,16309],{},[23,16307,16308],{},"子域要怎麼找？"," 只靠一個故事不夠，整個業務要從哪些角度看？",[69,16311,16312,16315],{},[23,16313,16314],{},"怎麼判斷是核心、支撐還是通用？"," 每個人都覺得自己負責的是核心",[69,16317,16318,16321],{},[23,16319,16320],{},"子域和限界上下文是一對一嗎？"," 系統要照子域切嗎？",[28,16323,16324],{"id":16324},"先分清楚三個名詞",[132,16326,16327,16342],{},[135,16328,16329],{},[138,16330,16331,16334,16337,16339],{},[141,16332,16333],{},"名詞",[141,16335,16336],{},"屬於",[141,16338,286],{},[141,16340,16341],{},"房仲的例子",[152,16343,16344,16358,16371],{},[138,16345,16346,16349,16352,16355],{},[157,16347,16348],{},"領域（Domain）",[157,16350,16351],{},"問題空間",[157,16353,16354],{},"公司從事的整個業務",[157,16356,16357],{},"房屋仲介",[138,16359,16360,16363,16365,16368],{},[157,16361,16362],{},"子域（Subdomain）",[157,16364,16351],{},[157,16366,16367],{},"業務中相對獨立的一塊能力",[157,16369,16370],{},"物件刊登、物件媒合、帶看、斡旋成交、佣金業績",[138,16372,16373,16376,16379,16382],{},[157,16374,16375],{},"限界上下文（Bounded Context）",[157,16377,16378],{},"解法空間",[157,16380,16381],{},"一個模型與它的語言有效的範圍，是系統的切法",[157,16383,16384],{},"刊登上下文、搜尋上下文、成交上下文",[11,16386,16387,16390],{},[23,16388,16389],{},"子域是「發現」的，限界上下文是「設計」的。"," 子域在寫任何程式之前就存在：就算公司全部用紙本作業，「帶看」這件事還是存在；限界上下文則是我們決定怎麼用軟體去解決它。",[28,16392,16394],{"id":16393},"第一步找出子域","第一步：找出子域",[11,16396,16397],{},"沒有一個公式能直接算出子域，但有幾個角度可以交叉比對：",[100,16399,16401],{"id":16400},"_1-從業務能力出發","1. 從業務能力出發",[11,16403,16404],{},"問「公司做哪些事？」，用「動詞＋名詞」列出業務能力，而不是列出系統功能或資料表：",[66,16406,16407,16410,16413,16416,16419,16422,16425],{},[69,16408,16409],{},"開發委託：說服屋主簽下委託",[69,16411,16412],{},"刊登物件：把物件資訊、照片放上網站",[69,16414,16415],{},"媒合買方：讓買方找到適合的物件",[69,16417,16418],{},"安排帶看：協調買方、屋主、經紀人的時間",[69,16420,16421],{},"斡旋議價：在買賣雙方之間協商價格",[69,16423,16424],{},"簽約交屋：簽訂買賣契約、交屋",[69,16426,16427],{},"計算佣金與業績：服務費怎麼拆給經紀人與分店",[100,16429,16431],{"id":16430},"_2-從流程中的關鍵事件切","2. 從流程中的關鍵事件切",[11,16433,16434,16435,16438],{},"跑過 Event Storming 或畫過粗粒度的領域故事後，時間軸上會有幾個",[23,16436,16437],{},"關鍵事件（Pivotal Event）","，它們是業務階段的分界：",[7381,16440,16441],{},[11,16442,16443],{},"委託已簽訂 → 物件已上架 → 帶看已完成 → 斡旋已接受 → 買賣契約已簽訂 → 已交屋",[11,16445,16446],{},"兩個關鍵事件之間的那一段，通常就是一個子域的候選。",[100,16448,16450],{"id":16449},"_3-從語言的變化","3. 從語言的變化",[11,16452,16453],{},"同一個詞意思開始不一樣的地方，就是邊界：",[66,16455,16456,16459],{},[69,16457,16458],{},"刊登時的「物件」是一則廣告，在意的是照片好不好看、文案吸不吸引人",[69,16460,16461],{},"成交時的「物件」是一個交易標的，在意的是權狀、地號、有沒有抵押",[100,16463,16465],{"id":16464},"_4-從組織與變化頻率","4. 從組織與變化頻率",[66,16467,16468,16474],{},[69,16469,16470,16473],{},[23,16471,16472],{},"誰負責？"," 刊登歸行銷、斡旋歸店長、佣金歸財務，不同部門的需求很少會同時改到",[69,16475,16476,16479],{},[23,16477,16478],{},"多常改？"," 搜尋與推薦幾乎每個月都在調整，佣金規則一年才改一次",[11,16481,16482,16483,114],{},"這四個角度得出的結果通常不會完全一致，",[23,16484,16485],{},"不一致的地方就是要拿去和領域專家討論的地方",[28,16487,16489],{"id":16488},"第二步分類","第二步：分類",[11,16491,16492,16493],{},"找出子域後，下一步是決定每一塊值得投資多少。分類的關鍵問題只有一個：",[23,16494,16495],{},"這一塊做得比對手好，客戶會因此選擇我們嗎？",[132,16497,16498,16511],{},[135,16499,16500],{},[138,16501,16502,16505,16508],{},[141,16503,16504],{},"類型",[141,16506,16507],{},"判斷問題",[141,16509,16510],{},"策略",[152,16512,16513,16524,16535],{},[138,16514,16515,16518,16521],{},[157,16516,16517],{},"核心子域（Core）",[157,16519,16520],{},"做得好會讓客戶選我們、做不好會失去客戶",[157,16522,16523],{},"自己做，放最好的人，用完整的 DDD 設計，持續投資",[138,16525,16526,16529,16532],{},[157,16527,16528],{},"支撐子域（Supporting）",[157,16530,16531],{},"業務需要、買不到現成的，但做得再好客戶也不會在意",[157,16533,16534],{},"自己做但做簡單，可以外包，不需要複雜的設計",[138,16536,16537,16540,16543],{},[157,16538,16539],{},"通用子域（Generic）",[157,16541,16542],{},"每家公司都有一樣的問題，市場上有成熟方案",[157,16544,16545],{},"買現成的、用開源或雲端服務，不要自己寫",[100,16547,16549],{"id":16548},"用-core-domain-chart-畫出來","用 Core Domain Chart 畫出來",[11,16551,16552,16553,16556],{},"只用文字分類容易流於主觀，可以用 Nick Tune 提出的 ",[23,16554,16555],{},"Core Domain Chart","，把每個子域放在兩個維度上：",[66,16558,16559,16565],{},[69,16560,16561,16564],{},[23,16562,16563],{},"橫軸：模型複雜度","，業務規則有多複雜、多難做對",[69,16566,16567,16570],{},[23,16568,16569],{},"縱軸：業務差異化","，對競爭優勢的影響有多大",[11,16572,16573],{},[1952,16574],{"alt":16575,"src":16576},"房仲業務的 Core Domain Chart：橫軸是模型複雜度，縱軸是業務差異化。上半部差異化高的區域是核心子域，物件媒合與推薦位在右上角，複雜度和差異化都最高，斡旋成交流程也在核心區；下半部差異化低，左下是支撐子域，包含物件刊登、帶看排程、佣金與業績；右下是通用子域，複雜但不帶來差異化，包含會員與登入、地圖與地理資訊、金流，這些應該買現成方案。右側說明各區的投資策略","\u002Fimages\u002Farticles\u002Fddd-core-domain-chart.svg",[11,16578,16579],{},"圖的讀法：",[66,16581,16582,16588,16594,16600],{},[69,16583,16584,16587],{},[23,16585,16586],{},"右上（核心）","：複雜又能帶來差異，是最值得投資的地方。物件媒合做得好，買方更快找到房子，經紀人的帶看成交率也更高",[69,16589,16590,16593],{},[23,16591,16592],{},"左下（支撐）","：簡單、不帶來差異，自己做但用最簡單的方式做。帶看排程只要能約時間、會提醒就夠了",[69,16595,16596,16599],{},[23,16597,16598],{},"右下（通用）","：很複雜，但做得好也不會讓客戶選我們。金流、身分驗證自己寫很容易出錯，應該買現成的",[69,16601,16602,16605],{},[23,16603,16604],{},"左上","：簡單但有差異化，可能是一個剛出現的機會，或是很快會被對手模仿的功能",[11,16607,16608,16609,16612],{},"佣金與業績是個有趣的例子：規則其實不簡單（專任／一般委託的比例不同、跨店合作要拆帳），但它不影響客戶的選擇，所以是支撐子域。",[23,16610,16611],{},"複雜不等於核心","，這是分類時最常見的誤判。",[100,16614,16615],{"id":16615},"分類不是永久的",[66,16617,16618,16624],{},[69,16619,16620,16623],{},[23,16621,16622],{},"公司策略決定核心","：同樣是房仲，主打「經紀人服務品質」的公司，帶看體驗可能就是核心；主打「線上找房」的公司，核心是搜尋和推薦",[69,16625,16626,16629],{},[23,16627,16628],{},"核心會變成通用","：十年前地圖找房是差異化，現在每個房屋網站都有，加上地圖服務越來越成熟，它就變成通用子域了",[11,16631,16632],{},"所以分類的結果要寫下來，並且定期（例如每半年）重新檢視一次。",[28,16634,16636],{"id":16635},"第三步對應到限界上下文","第三步：對應到限界上下文",[11,16638,16639,16640,16643],{},"理想上，",[23,16641,16642],{},"一個子域對應一個限界上下文","，邊界最清楚。但實際上常見幾種情況：",[66,16645,16646,16652,16658],{},[69,16647,16648,16651],{},[23,16649,16650],{},"一個子域、多個上下文","：物件媒合包含「搜尋」和「推薦」，兩者的模型不同（搜尋要的是篩選條件，推薦要的是瀏覽紀錄和相似度），可以切成兩個上下文",[69,16653,16654,16657],{},[23,16655,16656],{},"多個子域、一個上下文","：舊系統常常一個大系統包辦刊登、帶看、成交。短期內不一定要拆，但要知道它涵蓋了哪些子域，新功能盡量不要再往裡面塞",[69,16659,16660,16663],{},[23,16661,16662],{},"通用子域變成外部服務","：金流直接用第三方，在自己這邊加一層防腐層，把對方的格式轉成自己的模型",[11,16665,16666],{},[1952,16667],{"alt":16668,"src":16669},"子域與限界上下文的對應：上方是問題空間的子域，物件刊登與帶看排程是支撐子域，物件媒合與斡旋成交是核心子域，金流是通用子域；下方是解法空間的限界上下文，物件刊登對應刊登上下文，物件媒合拆成搜尋與推薦兩個上下文，帶看排程對應帶看上下文，斡旋成交對應成交上下文，金流對應外部金流服務。每個上下文裡的「物件」模型都不同：刊登上下文是標題、照片、文案，搜尋上下文是座標、價格、坪數，推薦上下文是瀏覽紀錄與相似物件，帶看上下文只存物件 Id 與時段，成交上下文是權狀、地號與所有權人。刊登上下文透過「物件已上架」事件以公開語言通知搜尋與推薦，成交上下文透過防腐層串接外部金流服務","\u002Fimages\u002Farticles\u002Fddd-subdomain-to-context.svg",[11,16671,16672,16673,16676],{},"注意帶看上下文的「物件」只有 Id：帶看只需要知道是哪一間、地址在哪，不需要照片或權狀。",[23,16674,16675],{},"每個上下文只保留自己需要的部分","，這正是限界上下文的意義。",[11,16678,16679],{},"上下文之間的關係（上下文映射）也要一起決定：",[66,16681,16682,16689],{},[69,16683,16684,16685],{},"刊登 → 搜尋、推薦：刊登上下文發布「物件已上架」、「物件已改價」事件，用統一格式給多個下游（開放主機服務／公開語言），搜尋端再依事件更新自己的索引，做法類似 ",[119,16686,16688],{"href":16687},"\u002Farticles\u002Fcqrs-elasticsearch-read-model","CQRS 的讀取模型",[69,16690,16691],{},"成交 → 外部金流：用防腐層把金流服務的欄位與狀態碼轉成成交上下文的模型，換金流商時只需要改這一層",[28,16693,16694],{"id":16694},"邊界切錯的徵兆",[11,16696,16697],{},"切完之後，可以用這些問題檢查：",[66,16699,16700,16706,16712,16718],{},[69,16701,16702,16705],{},[23,16703,16704],{},"一個需求要同時改好幾個上下文？"," 偶爾發生是正常的，經常發生代表相關的規則被切開了",[69,16707,16708,16711],{},[23,16709,16710],{},"上下文之間一直同步呼叫？"," 完成一件事要 A 呼叫 B、B 再呼叫 C，任何一個掛掉整個流程就失敗，代表它們其實是同一件事",[69,16713,16714,16717],{},[23,16715,16716],{},"一個上下文需要兩個團隊一起改？"," 限界上下文最好由一個團隊負責，否則每次修改都要協調",[69,16719,16720,16723],{},[23,16721,16722],{},"同一個上下文裡，同一個詞有兩種意思？"," 代表裡面其實有兩個模型，應該再切開",[11,16725,16726],{},"幾種常見的錯誤切法：",[132,16728,16729,16738],{},[135,16730,16731],{},[138,16732,16733,16736],{},[141,16734,16735],{},"切法",[141,16737,10643],{},[152,16739,16740,16748,16756,16764],{},[138,16741,16742,16745],{},[157,16743,16744],{},"依資料表切：物件服務、會員服務、訂單服務",[157,16746,16747],{},"每個業務流程都要跨好幾個服務，「物件服務」又變回所有人共用的大模型",[138,16749,16750,16753],{},[157,16751,16752],{},"依技術層切：前端團隊、API 團隊、資料庫團隊",[157,16754,16755],{},"任何功能都要三個團隊排程，沒有人對完整的業務負責",[138,16757,16758,16761],{},[157,16759,16760],{},"切得太細：每個聚合一個服務",[157,16762,16763],{},"服務之間的呼叫和資料同步成本超過拆分的好處",[138,16765,16766,16769],{},[157,16767,16768],{},"照組織圖切，但組織本身就不合理",[157,16770,16771],{},"康威定律會把組織的問題複製到系統裡，有時候要調整的是團隊分工",[28,16773,16775],{"id":16774},"不同類型不同做法","不同類型，不同做法",[11,16777,16778,16779,16782],{},"分類的目的是",[23,16780,16781],{},"把力氣花在對的地方","，不同類型的子域，架構和開發方式可以完全不同：",[132,16784,16785,16800],{},[135,16786,16787],{},[138,16788,16789,16791,16794,16797],{},[141,16790],{},[141,16792,16793],{},"核心子域",[141,16795,16796],{},"支撐子域",[141,16798,16799],{},"通用子域",[152,16801,16802,16816,16834,16847],{},[138,16803,16804,16807,16810,16813],{},[157,16805,16806],{},"設計方式",[157,16808,16809],{},"完整的戰術設計：聚合、值物件、領域事件",[157,16811,16812],{},"簡單的分層或 Transaction Script，CRUD 也可以",[157,16814,16815],{},"不自己設計，整合現成方案",[138,16817,16818,16821,16828,16831],{},[157,16819,16820],{},"架構",[157,16822,16823,16827],{},[119,16824,16826],{"href":16825},"\u002Farticles\u002Fhexagonal-architecture-dotnet","六角形架構","，隔離業務與技術",[157,16829,16830],{},"能動、好維護就好",[157,16832,16833],{},"用防腐層包起來",[138,16835,16836,16838,16841,16844],{},[157,16837,1233],{},[157,16839,16840],{},"業務規則要有完整的單元測試",[157,16842,16843],{},"主要流程的整合測試",[157,16845,16846],{},"測試整合的那一層",[138,16848,16849,16852,16855,16858],{},[157,16850,16851],{},"人力",[157,16853,16854],{},"最資深、最懂業務的人",[157,16856,16857],{},"新人、外包都可以",[157,16859,16860],{},"少量人力負責整合",[11,16862,16863],{},"「全部都用完整的 DDD」和「全部都不用」一樣不好。用在核心子域，才能得到 DDD 最大的回報。",[28,16865,1890],{"id":1890},[11,16867,16868],{},"子域是從業務能力、關鍵事件、語言變化與組織分工中「發現」出來的，用「做得好會不會讓客戶選我們」與模型複雜度把它們分成核心、支撐、通用；限界上下文則是我們「設計」的系統切法，理想上和子域一對一，但可以視模型差異再拆或暫時合併，每個上下文只保留自己需要的模型，最後把最多的心力放在核心子域。",{"title":44,"searchDepth":386,"depth":386,"links":16870},[16871,16872,16878,16882,16883,16884,16885],{"id":16324,"depth":386,"text":16324},{"id":16393,"depth":386,"text":16394,"children":16873},[16874,16875,16876,16877],{"id":16400,"depth":397,"text":16401},{"id":16430,"depth":397,"text":16431},{"id":16449,"depth":397,"text":16450},{"id":16464,"depth":397,"text":16465},{"id":16488,"depth":386,"text":16489,"children":16879},[16880,16881],{"id":16548,"depth":397,"text":16549},{"id":16615,"depth":397,"text":16615},{"id":16635,"depth":386,"text":16636},{"id":16694,"depth":386,"text":16694},{"id":16774,"depth":386,"text":16775},{"id":1890,"depth":386,"text":1890},"2024-04-30","DDD 概論 裡簡單介紹了子域和限界上下文，Domain Storytelling 那篇也從故事裡找出了「搜尋、帶看、斡旋」三個候選邊界。但真的要替整個系統劃分時，還有幾個問題要回答：",{},{"title":16291,"description":16887},"articles\u002Fddd-subdomains-bounded-context",[8964,11785,3037,3039,16892],"微服務","la84EVeNZ0qtKX0H9GDJd5eCDOTztgSnb7xdGfj3na4",{"id":16895,"title":16896,"body":16897,"date":17895,"description":17896,"excerpt":1156,"extension":1157,"meta":17897,"navigation":1035,"path":17898,"seo":17899,"stem":17900,"tags":17901,"__hash__":17904},"articles\u002Farticles\u002Fk8s-rolling-update-zero-downtime.md","Kubernetes 滾動更新怎麼做到真的不停機？maxSurge、Readiness、preStop 與 PodDisruptionBudget",{"type":8,"value":16898,"toc":17880},[16899,16910,16913,16933,16936,16942,16945,16970,16977,16980,17060,17074,17081,17085,17095,17109,17161,17168,17172,17178,17184,17190,17213,17220,17224,17234,17312,17315,17318,17340,17343,17346,17368,17377,17479,17493,17506,17510,17517,17545,17549,17560,17571,17649,17652,17655,17691,17694,17704,17724,17728,17731,17780,17821,17824,17826,17869,17871,17877],[11,16900,16901,16902,16906,16907],{},"Pod、Deployment、Service 的基本關係可以先參考 ",[119,16903,16905],{"href":16904},"\u002Farticles\u002Fk8s-core-objects","Kubernetes 入門","。這篇要回答的問題是：",[23,16908,16909],{},"Deployment 明明就是滾動更新，為什麼每次部署還是有一小段錯誤？",[11,16911,16912],{},"答案是，「不停機」需要三段都顧到：",[211,16914,16915,16921,16927],{},[69,16916,16917,16920],{},[23,16918,16919],{},"新 Pod","：真的準備好了才接流量",[69,16922,16923,16926],{},[23,16924,16925],{},"舊 Pod","：確定沒有流量了才關閉，關閉前把手上的請求處理完",[69,16928,16929,16932],{},[23,16930,16931],{},"其他原因的中斷","：Node 維護、叢集升級時，不能一次把 Pod 全部趕走",[28,16934,16935],{"id":16935},"滾動更新的流程",[11,16937,16938],{},[1952,16939],{"alt":16940,"src":16941},"Kubernetes 滾動更新流程，3 個副本、maxSurge 1、maxUnavailable 0：第一步先建立一個 v2 Pod，此時共有 4 個 Pod，v2 還沒通過 Readiness 不接流量；第二步 v2 Pod 就緒後加入 Service，再終止一個 v1 Pod；重複這個過程，第三步、第四步依序替換，最後 3 個 Pod 全部是 v2；整個過程中可接流量的 Pod 從未少於 3 個","\u002Fimages\u002Farticles\u002Fk8s-rolling-update.svg",[11,16943,16944],{},"修改 Deployment 的映像檔版本後，Deployment 會建立一個新的 ReplicaSet，然後依兩個參數逐步替換：",[66,16946,16947,16959],{},[69,16948,16949,16954,16955,16958],{},[23,16950,16951],{},[15,16952,16953],{},"maxSurge","：更新過程中，最多可以",[23,16956,16957],{},"比期望數量多幾個"," Pod",[69,16960,16961,16966,16967],{},[23,16962,16963],{},[15,16964,16965],{},"maxUnavailable","：更新過程中，最多可以有",[23,16968,16969],{},"幾個 Pod 不可用",[11,16971,16972,16973,16976],{},"預設都是 ",[15,16974,16975],{},"25%","。以 4 個副本為例，代表最多可以多開 1 個、同時可以有 1 個不可用。",[11,16978,16979],{},"對線上服務，我會這樣設：",[36,16981,16983],{"className":370,"code":16982,"language":372,"meta":44,"style":44},"spec:\n  replicas: 3\n  strategy:\n    type: RollingUpdate\n    rollingUpdate:\n      maxSurge: 1          # 每次多開一個新的\n      maxUnavailable: 0    # 新的就緒之前，舊的一個都不關\n  minReadySeconds: 10      # 就緒後再觀察 10 秒，才算可用\n",[15,16984,16985,16991,17000,17007,17017,17024,17036,17048],{"__ignoreMap":44},[376,16986,16987,16989],{"class":378,"line":379},[376,16988,509],{"class":389},[376,16990,394],{"class":393},[376,16992,16993,16996,16998],{"class":378,"line":386},[376,16994,16995],{"class":389},"  replicas",[376,16997,411],{"class":393},[376,16999,3638],{"class":560},[376,17001,17002,17005],{"class":378,"line":397},[376,17003,17004],{"class":389},"  strategy",[376,17006,394],{"class":393},[376,17008,17009,17012,17014],{"class":378,"line":405},[376,17010,17011],{"class":389},"    type",[376,17013,411],{"class":393},[376,17015,17016],{"class":414},"RollingUpdate\n",[376,17018,17019,17022],{"class":378,"line":418},[376,17020,17021],{"class":389},"    rollingUpdate",[376,17023,394],{"class":393},[376,17025,17026,17029,17031,17033],{"class":378,"line":429},[376,17027,17028],{"class":389},"      maxSurge",[376,17030,411],{"class":393},[376,17032,892],{"class":560},[376,17034,17035],{"class":382},"          # 每次多開一個新的\n",[376,17037,17038,17041,17043,17045],{"class":378,"line":437},[376,17039,17040],{"class":389},"      maxUnavailable",[376,17042,411],{"class":393},[376,17044,4975],{"class":560},[376,17046,17047],{"class":382},"    # 新的就緒之前，舊的一個都不關\n",[376,17049,17050,17053,17055,17057],{"class":378,"line":447},[376,17051,17052],{"class":389},"  minReadySeconds",[376,17054,411],{"class":393},[376,17056,361],{"class":560},[376,17058,17059],{"class":382},"      # 就緒後再觀察 10 秒，才算可用\n",[11,17061,17062,17065,17066,17069,17070,17073],{},[15,17063,17064],{},"maxUnavailable: 0"," 代表",[23,17067,17068],{},"可接流量的 Pod 永遠不會少於 3 個","：先開一個新的，等它就緒，才關一個舊的。代價是更新期間需要多一個 Pod 的資源，叢集資源不夠時，新 Pod 會卡在 ",[15,17071,17072],{},"Pending","，整個更新就停住。",[11,17075,17076,17077,17080],{},"另一種策略 ",[15,17078,17079],{},"Recreate"," 是先全部關掉再全部開新的，一定會停機，只適合「新舊版本絕對不能同時存在」的情況。",[100,17082,17084],{"id":17083},"readiness-probe判斷新-pod-準備好了","Readiness Probe：判斷「新 Pod 準備好了」",[11,17086,17087,17088,17091,17092,114],{},"上面的流程有一個關鍵：「",[23,17089,17090],{},"新 Pod 就緒","」是怎麼判斷的？答案是 ",[23,17093,17094],{},"Readiness Probe",[66,17096,17097,17103],{},[69,17098,17099,17102],{},[23,17100,17101],{},"沒有設定 Readiness Probe","：容器一啟動就被視為就緒，Deployment 馬上關掉一個舊的；但 .NET 應用程式可能還在暖機、建立資料庫連線池，前幾秒的請求會變慢或失敗",[69,17104,17105,17108],{},[23,17106,17107],{},"有設定","：要等探針通過，Pod 才會被加進 Service，Deployment 才會繼續下一步",[36,17110,17112],{"className":370,"code":17111,"language":372,"meta":44,"style":44},"readinessProbe:\n  httpGet:\n    path: \u002Fhealth\u002Fready\n    port: 8080\n  periodSeconds: 5\n  failureThreshold: 3\n",[15,17113,17114,17120,17126,17135,17145,17153],{"__ignoreMap":44},[376,17115,17116,17118],{"class":378,"line":379},[376,17117,3647],{"class":389},[376,17119,394],{"class":393},[376,17121,17122,17124],{"class":378,"line":386},[376,17123,3586],{"class":389},[376,17125,394],{"class":393},[376,17127,17128,17130,17132],{"class":378,"line":397},[376,17129,7153],{"class":389},[376,17131,411],{"class":393},[376,17133,17134],{"class":414},"\u002Fhealth\u002Fready\n",[376,17136,17137,17140,17142],{"class":378,"line":405},[376,17138,17139],{"class":389},"    port",[376,17141,411],{"class":393},[376,17143,17144],{"class":560},"8080\n",[376,17146,17147,17149,17151],{"class":378,"line":418},[376,17148,3624],{"class":389},[376,17150,411],{"class":393},[376,17152,3681],{"class":560},[376,17154,17155,17157,17159],{"class":378,"line":429},[376,17156,3633],{"class":389},[376,17158,411],{"class":393},[376,17160,3638],{"class":560},[11,17162,17163,17164,17167],{},"Readiness 端點應該檢查「這個實例能不能正常處理請求」，例如必要的設定與快取載入完成、資料庫連得上。新版本如果一直無法就緒，滾動更新會停在那裡，",[23,17165,17166],{},"舊版本的 Pod 還在","，服務不會中斷，這是 Readiness 帶來的另一個保護。",[28,17169,17171],{"id":17170},"舊-pod-的關閉最容易被忽略的-502","舊 Pod 的關閉：最容易被忽略的 502",[11,17173,17174,17175,114],{},"即使新 Pod 都正確就緒了，很多人還是會在部署時看到一小段 502。原因出在",[23,17176,17177],{},"舊 Pod 關閉的方式",[11,17179,17180],{},[1952,17181],{"alt":17182,"src":17183},"Pod 終止的時序比較：Pod 被標記為 Terminating 後，有兩件事同時開始。一條線是 Endpoints 控制器把 Pod 從 Service 移除，再由 kube-proxy 與 Ingress Controller 更新各自的轉發規則，這需要幾秒；另一條線是 kubelet 送出 SIGTERM。沒有 preStop 時，應用程式收到 SIGTERM 立刻停止接受連線，但轉發規則還沒更新，仍有請求被送進來，造成 502。加上 preStop sleep 10 秒後，SIGTERM 延後送出，這段時間轉發規則已經更新完成，應用程式接著處理完手上的請求再結束，整體時間要在 terminationGracePeriodSeconds 之內","\u002Fimages\u002Farticles\u002Fk8s-pod-termination.svg",[11,17185,17186,17187,17189],{},"一個 Pod 被刪除時，K8s 會",[23,17188,12957],{},"做兩件事：",[211,17191,17192,17201],{},[69,17193,17194,17197,17198],{},[23,17195,17196],{},"把 Pod 從 Service 移除","：Endpoints 更新後，各個 Node 上的 kube-proxy、Ingress Controller 要各自更新轉發規則，",[23,17199,17200],{},"這需要幾秒鐘",[69,17202,17203,17206,17207,17209,17210],{},[23,17204,17205],{},"通知容器關閉","：kubelet 執行 ",[15,17208,3863],{},"（如果有），然後送出 ",[23,17211,17212],{},"SIGTERM",[11,17214,17215,17216,17219],{},"問題就在「同時」。應用程式收到 SIGTERM 就停止接受新連線，但轉發規則可能還沒更新完，",[23,17217,17218],{},"還有請求被送到這個正在關閉的 Pod","，結果就是連線被拒絕、502。",[100,17221,17223],{"id":17222},"prestop先等一下再關","preStop：先等一下再關",[11,17225,17226,17227,17229,17230,17233],{},"解法是用 ",[15,17228,3863],{}," 讓 SIGTERM ",[23,17231,17232],{},"晚幾秒再送","，給轉發規則足夠的時間更新：",[36,17235,17237],{"className":370,"code":17236,"language":372,"meta":44,"style":44},"spec:\n  terminationGracePeriodSeconds: 45\n  containers:\n    - name: house-api\n      lifecycle:\n        preStop:\n          exec:\n            command: [\"sleep\", \"10\"]\n",[15,17238,17239,17245,17255,17262,17274,17281,17288,17295],{"__ignoreMap":44},[376,17240,17241,17243],{"class":378,"line":379},[376,17242,509],{"class":389},[376,17244,394],{"class":393},[376,17246,17247,17250,17252],{"class":378,"line":386},[376,17248,17249],{"class":389},"  terminationGracePeriodSeconds",[376,17251,411],{"class":393},[376,17253,17254],{"class":560},"45\n",[376,17256,17257,17260],{"class":378,"line":397},[376,17258,17259],{"class":389},"  containers",[376,17261,394],{"class":393},[376,17263,17264,17266,17269,17271],{"class":378,"line":405},[376,17265,586],{"class":393},[376,17267,17268],{"class":389},"name",[376,17270,411],{"class":393},[376,17272,17273],{"class":414},"house-api\n",[376,17275,17276,17279],{"class":378,"line":418},[376,17277,17278],{"class":389},"      lifecycle",[376,17280,394],{"class":393},[376,17282,17283,17286],{"class":378,"line":429},[376,17284,17285],{"class":389},"        preStop",[376,17287,394],{"class":393},[376,17289,17290,17293],{"class":378,"line":437},[376,17291,17292],{"class":389},"          exec",[376,17294,394],{"class":393},[376,17296,17297,17300,17302,17305,17307,17310],{"class":378,"line":447},[376,17298,17299],{"class":389},"            command",[376,17301,7712],{"class":393},[376,17303,17304],{"class":414},"\"sleep\"",[376,17306,3188],{"class":393},[376,17308,17309],{"class":414},"\"10\"",[376,17311,7718],{"class":393},[11,17313,17314],{},"這 10 秒內 Pod 照常處理請求，但已經不會有新的請求進來了。10 秒後才送 SIGTERM，應用程式開始正常關閉。",[11,17316,17317],{},"要注意兩件事：",[66,17319,17320,17328],{},[69,17321,17322,17327],{},[23,17323,17324,17326],{},[15,17325,3859],{}," 包含 preStop 的時間","。預設 30 秒，時間到了不管做完沒有都會送 SIGKILL 強制結束。preStop 10 秒、應用程式關閉最多 30 秒，這裡就要設 40 秒以上",[69,17329,17330,17336,17337,17339],{},[23,17331,17332,17333],{},"映像檔裡要有 ",[15,17334,17335],{},"sleep","：精簡過的映像檔（例如 distroless、chiseled）沒有 shell，也沒有 ",[15,17338,17335],{}," 指令，preStop 會執行失敗。這種情況可以改在應用程式裡處理（見下一節）",[100,17341,17342],{"id":17342},"應用程式端的優雅關閉",[11,17344,17345],{},"收到 SIGTERM 後，ASP.NET Core 會：",[211,17347,17348,17351,17357],{},[69,17349,17350],{},"停止接受新的連線",[69,17352,1263,17353,17356],{},[23,17354,17355],{},"正在處理中的請求","完成",[69,17358,17359,17360,17363,17364,17367],{},"呼叫所有 ",[15,17361,17362],{},"IHostedService"," 的 ",[15,17365,17366],{},"StopAsync","，讓背景工作收尾",[11,17369,17370,17371,17374,17375,55],{},"等待的上限由 ",[15,17372,17373],{},"HostOptions.ShutdownTimeout"," 決定（.NET 6 之後預設 30 秒），要確保它加上 preStop 的時間，小於 ",[15,17376,3859],{},[36,17378,17380],{"className":3145,"code":17379,"language":3147,"meta":44,"style":44},"builder.Services.Configure\u003CHostOptions>(options =>\n{\n    options.ShutdownTimeout = TimeSpan.FromSeconds(25);\n});\n\n\u002F\u002F 映像檔沒有 sleep 可用時，在應用程式裡延後關閉，效果等同 preStop\nvar app = builder.Build();\napp.Lifetime.ApplicationStopping.Register(() => Thread.Sleep(TimeSpan.FromSeconds(10)));\n",[15,17381,17382,17401,17405,17423,17427,17431,17436,17453],{"__ignoreMap":44},[376,17383,17384,17386,17389,17391,17394,17396,17399],{"class":378,"line":379},[376,17385,3159],{"class":393},[376,17387,17388],{"class":1020},"Configure",[376,17390,3176],{"class":393},[376,17392,17393],{"class":1020},"HostOptions",[376,17395,3182],{"class":393},[376,17397,17398],{"class":1020},"options",[376,17400,12037],{"class":3196},[376,17402,17403],{"class":378,"line":386},[376,17404,3324],{"class":393},[376,17406,17407,17410,17412,17414,17416,17418,17421],{"class":378,"line":397},[376,17408,17409],{"class":393},"    options.ShutdownTimeout ",[376,17411,3240],{"class":3196},[376,17413,9368],{"class":393},[376,17415,3832],{"class":1020},[376,17417,3220],{"class":393},[376,17419,17420],{"class":560},"25",[376,17422,3376],{"class":393},[376,17424,17425],{"class":378,"line":405},[376,17426,3853],{"class":393},[376,17428,17429],{"class":378,"line":418},[376,17430,1036],{"emptyLinePlaceholder":1035},[376,17432,17433],{"class":378,"line":429},[376,17434,17435],{"class":382},"\u002F\u002F 映像檔沒有 sleep 可用時，在應用程式裡延後關閉，效果等同 preStop\n",[376,17437,17438,17440,17443,17445,17448,17451],{"class":378,"line":437},[376,17439,4684],{"class":3196},[376,17441,17442],{"class":1020}," app",[376,17444,3313],{"class":3196},[376,17446,17447],{"class":393}," builder.",[376,17449,17450],{"class":1020},"Build",[376,17452,3532],{"class":393},[376,17454,17455,17457,17459,17461,17463,17466,17468,17470,17472,17474,17476],{"class":378,"line":447},[376,17456,3769],{"class":393},[376,17458,3772],{"class":1020},[376,17460,3775],{"class":393},[376,17462,5665],{"class":3196},[376,17464,17465],{"class":393}," Thread.",[376,17467,3826],{"class":1020},[376,17469,3829],{"class":393},[376,17471,3832],{"class":1020},[376,17473,3220],{"class":393},[376,17475,361],{"class":560},[376,17477,17478],{"class":393},")));\n",[11,17480,9999,17481,17484,17485,17492],{},[15,17482,17483],{},"ApplicationStopping"," 延後時，這 10 秒是在 SIGTERM 之後才開始算，所以會",[23,17486,17487,17488,17491],{},"佔用 ",[15,17489,17490],{},"ShutdownTimeout"," 的時間","，設定時要一起算進去。",[11,17494,17495,17496,17498,17499,17505],{},"背景工作（例如消費佇列的 ",[15,17497,9005],{},"）要",[23,17500,17501,17502],{},"檢查 ",[15,17503,17504],{},"stoppingToken","，收到取消就停止拿新的工作、把手上這筆做完或放回佇列，而不是做到一半被砍掉。",[100,17507,17509],{"id":17508},"長連線websocket-與-signalr","長連線：WebSocket 與 SignalR",[11,17511,17512,17513,17516],{},"HTTP 請求幾秒就結束了，但 WebSocket 連線可能維持好幾個小時，",[23,17514,17515],{},"舊 Pod 關閉時，連在上面的連線一定會斷","。這沒辦法在伺服器端完全避免，要從架構上處理：",[66,17518,17519,17529,17539],{},[69,17520,17521,17524,17525,17528],{},[23,17522,17523],{},"用戶端一定要自動重連","：SignalR 用戶端加上 ",[15,17526,17527],{},"withAutomaticReconnect()","，斷線後會重新連到新的 Pod",[69,17530,17531,17534,17535],{},[23,17532,17533],{},"狀態不能只存在連線上","：例如對話的進度、訂閱的頻道，重連後要能恢復。多實例下的做法可以參考 ",[119,17536,17538],{"href":17537},"\u002Farticles\u002Fsignalr-streaming-gpt","SignalR 串流與多實例部署",[69,17540,17541,17544],{},[23,17542,17543],{},"滾動更新的速度放慢","：每次只換一個 Pod，避免所有用戶同時重連，把新 Pod 打爆",[28,17546,17548],{"id":17547},"其他中斷poddisruptionbudget","其他中斷：PodDisruptionBudget",[11,17550,17551,17552,17555,17556,17559],{},"除了部署，Pod 也會因為",[23,17553,17554],{},"計畫中的維護","被趕走：Node 要升級、叢集要縮減 Node 時，管理員會 ",[15,17557,17558],{},"kubectl drain","，把那台 Node 上的 Pod 全部驅逐。",[11,17561,17562,17563,17566,17567,17570],{},"如果 3 個 Pod 剛好都在同一台 Node 上，drain 時",[23,17564,17565],{},"3 個會一起被驅逐","，服務就中斷了。",[23,17568,17569],{},"PodDisruptionBudget（PDB）"," 就是用來限制這件事：",[36,17572,17574],{"className":370,"code":17573,"language":372,"meta":44,"style":44},"apiVersion: policy\u002Fv1\nkind: PodDisruptionBudget\nmetadata:\n  name: house-api\nspec:\n  minAvailable: 2          # 任何時候至少要有 2 個可用\n  selector:\n    matchLabels:\n      app: house-api\n",[15,17575,17576,17585,17594,17600,17608,17614,17626,17633,17640],{"__ignoreMap":44},[376,17577,17578,17580,17582],{"class":378,"line":379},[376,17579,472],{"class":389},[376,17581,411],{"class":393},[376,17583,17584],{"class":414},"policy\u002Fv1\n",[376,17586,17587,17589,17591],{"class":378,"line":386},[376,17588,482],{"class":389},[376,17590,411],{"class":393},[376,17592,17593],{"class":414},"PodDisruptionBudget\n",[376,17595,17596,17598],{"class":378,"line":397},[376,17597,492],{"class":389},[376,17599,394],{"class":393},[376,17601,17602,17604,17606],{"class":378,"line":405},[376,17603,499],{"class":389},[376,17605,411],{"class":393},[376,17607,17273],{"class":414},[376,17609,17610,17612],{"class":378,"line":418},[376,17611,509],{"class":389},[376,17613,394],{"class":393},[376,17615,17616,17619,17621,17623],{"class":378,"line":429},[376,17617,17618],{"class":389},"  minAvailable",[376,17620,411],{"class":393},[376,17622,347],{"class":560},[376,17624,17625],{"class":382},"          # 任何時候至少要有 2 個可用\n",[376,17627,17628,17631],{"class":378,"line":437},[376,17629,17630],{"class":389},"  selector",[376,17632,394],{"class":393},[376,17634,17635,17638],{"class":378,"line":447},[376,17636,17637],{"class":389},"    matchLabels",[376,17639,394],{"class":393},[376,17641,17642,17645,17647],{"class":378,"line":531},[376,17643,17644],{"class":389},"      app",[376,17646,411],{"class":393},[376,17648,17273],{"class":414},[11,17650,17651],{},"有了 PDB，drain 一次只能驅逐到「剩下 2 個可用」為止，要等被驅逐的 Pod 在其他 Node 重建就緒後，才能繼續驅逐下一個。",[11,17653,17654],{},"幾個要注意的地方：",[66,17656,17657,17663,17672,17682],{},[69,17658,17659,17662],{},[23,17660,17661],{},"PDB 只管「自願中斷」","：drain、叢集自動縮減這類主動驅逐。Node 突然當機、Pod 被 OOMKilled 不在它的控制範圍內",[69,17664,17665,17668,17669,17671],{},[23,17666,17667],{},"PDB 不影響 Deployment 的滾動更新","，那是由 ",[15,17670,16965],{}," 控制的",[69,17673,17674,17677,17678,17681],{},[23,17675,17676],{},"不要設得太嚴格","：3 個副本設 ",[15,17679,17680],{},"minAvailable: 3","，等於任何 Pod 都不能被驅逐，drain 會一直卡住，維運人員只能強制處理",[69,17683,17684,17687,17688,17690],{},[23,17685,17686],{},"搭配分散排程","：用 ",[15,17689,992],{}," 或 Pod Anti-Affinity，讓副本分散在不同的 Node，一台 Node 出事不會帶走全部",[28,17692,17693],{"id":17693},"新舊版本會同時存在",[11,17695,17696,17697,17700,17701,114],{},"滾動更新還有一個",[23,17698,17699],{},"應用程式層面","的影響：更新期間，v1 和 v2 會",[23,17702,17703],{},"同時在處理請求",[66,17705,17706,17712,17718],{},[69,17707,17708,17711],{},[23,17709,17710],{},"資料庫結構變更要向下相容","：v2 要刪除一個欄位，如果 migration 先跑了，還在運作的 v1 就會出錯。正確做法是分階段：先部署「不再使用這個欄位」的版本，下一次部署才真正刪除欄位（Expand／Contract）",[69,17713,17714,17717],{},[23,17715,17716],{},"API 的回應格式要相容","：前端可能先拿到 v2 的頁面、再呼叫到 v1 的 API，或反過來",[69,17719,17720,17723],{},[23,17721,17722],{},"訊息格式要相容","：v2 發出的新格式訊息，可能被還沒更新的 v1 Consumer 收到",[28,17725,17727],{"id":17726},"使用情境物件查詢-api-的部署設定","使用情境：物件查詢 API 的部署設定",[11,17729,17730],{},"把上面的設定組合起來：",[66,17732,17733,17744,17747,17759,17769],{},[69,17734,17735,17736,2276,17739,2276,17741],{},"3 個副本、",[15,17737,17738],{},"maxSurge: 1",[15,17740,17064],{},[15,17742,17743],{},"minReadySeconds: 10",[69,17745,17746],{},"Readiness 檢查資料庫連線與設定載入；Liveness 只檢查行程是否還活著",[69,17748,17749,17751,17752,17755,17756,17758],{},[15,17750,3863],{}," sleep 10 秒，",[15,17753,17754],{},"terminationGracePeriodSeconds: 45","，ASP.NET Core 的 ",[15,17757,17490],{}," 25 秒",[69,17760,17761,17762,17765,17766,17768],{},"PDB ",[15,17763,17764],{},"minAvailable: 2","，搭配 ",[15,17767,992],{}," 讓 3 個 Pod 分散在不同 Node",[69,17770,17771,17772,17775,17776,17779],{},"部署時用 ",[15,17773,17774],{},"kubectl rollout status"," 等待完成，失敗就 ",[15,17777,17778],{},"kubectl rollout undo"," 退回",[36,17781,17783],{"className":1006,"code":17782,"language":1008,"meta":44,"style":44},"kubectl rollout status deployment\u002Fhouse-api -n realty --timeout=5m\nkubectl rollout undo deployment\u002Fhouse-api -n realty\n",[15,17784,17785,17805],{"__ignoreMap":44},[376,17786,17787,17789,17792,17794,17797,17799,17802],{"class":378,"line":379},[376,17788,1021],{"class":1020},[376,17790,17791],{"class":414}," rollout",[376,17793,14211],{"class":414},[376,17795,17796],{"class":414}," deployment\u002Fhouse-api",[376,17798,15063],{"class":560},[376,17800,17801],{"class":414}," realty",[376,17803,17804],{"class":560}," --timeout=5m\n",[376,17806,17807,17809,17811,17814,17816,17818],{"class":378,"line":386},[376,17808,1021],{"class":1020},[376,17810,17791],{"class":414},[376,17812,17813],{"class":414}," undo",[376,17815,17796],{"class":414},[376,17817,15063],{"class":560},[376,17819,17820],{"class":414}," realty\n",[11,17822,17823],{},"部署期間持續對服務打請求驗證，整個滾動更新過程不會出現 5xx。",[28,17825,4494],{"id":4494},[66,17827,17828,17834,17840,17852,17863],{},[69,17829,17830,17833],{},[23,17831,17832],{},"Liveness 不要檢查外部依賴","：資料庫一慢，所有 Pod 的 Liveness 一起失敗、一起被重啟，小問題直接變成全面中斷",[69,17835,17836,17839],{},[23,17837,17838],{},"啟動很慢的應用程式要設 Startup Probe","：否則 Liveness 可能在應用程式還沒啟動完時就判定失敗，Pod 一直重啟",[69,17841,17842,17847,17848,17851],{},[23,17843,17844],{},[15,17845,17846],{},"progressDeadlineSeconds","：新 Pod 一直無法就緒時，Deployment 在這個時間（預設 600 秒）後會標記為失敗。CI\u002FCD 要檢查這個狀態，而不是 ",[15,17849,17850],{},"kubectl apply"," 成功就以為部署完成",[69,17853,17854,55,17857,17859,17860,17862],{},[23,17855,17856],{},"資源要預留",[15,17858,16953],{}," 需要額外的資源，叢集剛好用滿時，新 Pod 會卡在 ",[15,17861,17072],{},"，部署就卡住了",[69,17864,17865,17868],{},[23,17866,17867],{},"不要只在測試環境驗證","：測試環境流量小，轉發規則的時間差不容易出現問題。正式環境部署時要搭配監控，觀察 5xx 是否在部署期間上升",[28,17870,1890],{"id":1890},[11,17872,17873,17874,17876],{},"滾動更新要真的不停機，新 Pod 端靠 ",[15,17875,17064],{}," 加 Readiness Probe 確保就緒才接流量，舊 Pod 端靠 preStop 等轉發規則更新完、再讓應用程式在寬限時間內處理完手上的請求，長連線要靠用戶端自動重連，Node 維護時靠 PodDisruptionBudget 和分散排程避免一次被趕光；最後別忘了，更新期間新舊版本會同時存在，資料庫與 API 的變更都要向下相容。",[1132,17878,17879],{},"html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",{"title":44,"searchDepth":386,"depth":386,"links":17881},[17882,17885,17890,17891,17892,17893,17894],{"id":16935,"depth":386,"text":16935,"children":17883},[17884],{"id":17083,"depth":397,"text":17084},{"id":17170,"depth":386,"text":17171,"children":17886},[17887,17888,17889],{"id":17222,"depth":397,"text":17223},{"id":17342,"depth":397,"text":17342},{"id":17508,"depth":397,"text":17509},{"id":17547,"depth":386,"text":17548},{"id":17693,"depth":386,"text":17693},{"id":17726,"depth":386,"text":17727},{"id":4494,"depth":386,"text":4494},{"id":1890,"depth":386,"text":1890},"2024-04-09","Pod、Deployment、Service 的基本關係可以先參考 Kubernetes 入門。這篇要回答的問題是：Deployment 明明就是滾動更新，為什麼每次部署還是有一小段錯誤？",{},"\u002Farticles\u002Fk8s-rolling-update-zero-downtime",{"title":16896,"description":17896},"articles\u002Fk8s-rolling-update-zero-downtime",[1163,17902,17903,3902],"部署","高可用","oSfE-kR88Uf4lImEzvYozRb_KrPw5_gOIxZLbD20dHk",{"id":17906,"title":17907,"body":17908,"date":18431,"description":18432,"excerpt":1156,"extension":1157,"meta":18433,"navigation":1035,"path":11081,"seo":18434,"stem":18435,"tags":18436,"__hash__":18437},"articles\u002Farticles\u002Fdomain-storytelling.md","領域故事化（Domain Storytelling）：讓業務用說故事的方式講需求，再從故事裡找出上下文邊界",{"type":8,"value":17909,"toc":18417},[17910,17916,17923,17927,17930,17998,18001,18029,18032,18036,18039,18045,18052,18055,18058,18061,18095,18098,18136,18145,18148,18155,18158,18162,18165,18171,18221,18224,18238,18241,18248,18251,18254,18280,18295,18299,18302,18371,18378,18380,18412,18414],[11,17911,17912,17913,17915],{},"在 ",[119,17914,8738],{"href":8737}," 提到，DDD 的前提是真的理解業務，而理解業務的方法之一是 Event Storming。不過實際和業務同事坐下來時，我發現 Event Storming 對第一次參加的人有點門檻：要先解釋什麼是「領域事件」、為什麼要用過去式、橘色和藍色差在哪裡。",[11,17917,17918,17919,17922],{},"Domain Storytelling（領域故事化）是另一種協作建模的方法，門檻低很多：",[23,17920,17921],{},"請業務照著平常的工作講一個故事，主持人一邊聽一邊畫","。它來自 Stefan Hofer 和 Henning Schwentner，兩人在 2021 年出版了《Domain Storytelling》一書，把這套方法整理成完整的做法。",[28,17924,17926],{"id":17925},"圖示語法用簡單的句子拼成故事","圖示語法：用簡單的句子拼成故事",[11,17928,17929],{},"Domain Storytelling 的圖只有幾種元素：",[132,17931,17932,17942],{},[135,17933,17934],{},[138,17935,17936,17938,17940],{},[141,17937,11259],{},[141,17939,286],{},[141,17941,10617],{},[152,17943,17944,17954,17965,17976,17987],{},[138,17945,17946,17948,17951],{},[157,17947,11297],{},[157,17949,17950],{},"故事裡做事的人、群體或系統",[157,17952,17953],{},"買方、經紀人、屋主、業務系統",[138,17955,17956,17959,17962],{},[157,17957,17958],{},"工作物件（Work Object）",[157,17960,17961],{},"角色處理、傳遞、產生的東西",[157,17963,17964],{},"帶看紀錄、斡旋書、斡旋金",[138,17966,17967,17970,17973],{},[157,17968,17969],{},"活動（Activity）",[157,17971,17972],{},"連接角色和工作物件的箭頭，用動詞標示",[157,17974,17975],{},"預約、確認、轉交",[138,17977,17978,17981,17984],{},[157,17979,17980],{},"序號",[157,17982,17983],{},"標在活動上，表示故事的順序",[157,17985,17986],{},"①、②、③",[138,17988,17989,17992,17995],{},[157,17990,17991],{},"註解",[157,17993,17994],{},"補充說明、規則或疑問",[157,17996,17997],{},"「還價是另一個故事」",[11,17999,18000],{},"每一步都是一個簡單的句子：",[7381,18002,18003,18018],{},[11,18004,18005,18007,18008,18007,18011,18014,18015],{},[23,18006,147],{}," → ",[23,18009,18010],{},"活動",[23,18012,18013],{},"工作物件"," →（介系詞）→ ",[23,18016,18017],{},"另一個角色",[11,18019,18020,18021,18024,18025,18028],{},"例如：買方 ",[23,18022,18023],{},"提出"," 斡旋 ",[23,18026,18027],{},"給"," 經紀人",[11,18030,18031],{},"句子越簡單越好。如果一句話需要「如果…就…否則…」才講得完，通常代表它其實是兩個故事。",[28,18033,18035],{"id":18034},"例子從找房到斡旋","例子：從找房到斡旋",[11,18037,18038],{},"下面是我和一位資深經紀人訪談後畫出來的故事，描述一位買方從找到物件、看屋，到提出斡旋的過程：",[11,18040,18041],{},[1952,18042],{"alt":18043,"src":18044},"房仲帶看到斡旋的領域故事圖：買方、經紀人、屋主三個角色各只出現一次，左上是網站、下方是業務系統，經紀人位在中間，所有往來都經過他。九個帶序號的箭頭組成故事，每一句是「角色、動詞、工作物件、介系詞、角色」：1. 買方在網站搜尋物件；2. 買方向經紀人預約帶看；3. 經紀人和屋主確認帶看時間；4. 經紀人陪同買方帶看物件；5. 經紀人在業務系統填寫帶看紀錄；6. 買方提出斡旋書與斡旋金給經紀人；7. 經紀人轉交斡旋書給屋主；8. 屋主簽回已接受的斡旋書給經紀人；9. 經紀人通知斡旋結果給買方。序號依顏色分成三段：1 是搜尋，2 到 5 是帶看，6 到 9 是斡旋，代表從故事找出的候選上下文","\u002Fimages\u002Farticles\u002Fdomain-storytelling-viewing-offer.svg",[11,18046,18047,18048,18051],{},"每個角色在圖上只出現一次，所以畫完一眼就能看出整體的形狀：",[23,18049,18050],{},"買方和屋主之間從來沒有直接往來，所有的東西都經過經紀人","。這在逐條的需求清單裡看不出來，但它解釋了為什麼經紀人總是很忙，也指出了之後可以改善的地方，例如通知斡旋結果能不能由系統自動發送。",[11,18053,18054],{},"畫的時候，經紀人講到第 5 步，我問「帶看紀錄寫在哪？」，答案是「以前寫紙本帶看單，現在用業務系統」。再追問「帶看紀錄之後誰會看？」，才知道店長每週會看帶看紀錄來決定哪些物件要調價。這些細節在需求文件裡完全沒有，卻直接影響帶看紀錄要存哪些欄位。",[28,18056,18057],{"id":18057},"一場工作坊怎麼進行",[100,18059,18060],{"id":18060},"參與者與分工",[66,18062,18063,18073,18079,18089],{},[69,18064,18065,18068,18069,18072],{},[23,18066,18067],{},"說故事的人","：領域專家，例如資深經紀人、店長。最好是",[23,18070,18071],{},"實際做這件事的人","，而不是只知道規定的主管",[69,18074,18075,18078],{},[23,18076,18077],{},"主持人（Moderator）","：引導對話、控制節奏，確保一次只講一步",[69,18080,18081,18084,18085,18088],{},[23,18082,18083],{},"建模者（Modeler）","：負責畫圖，通常就是主持人自己。畫的時候要",[23,18086,18087],{},"大聲重述","每一句：「所以是經紀人把斡旋書交給屋主，對嗎？」",[69,18090,18091,18094],{},[23,18092,18093],{},"聽眾","：開發人員、PO。主要聽，必要時提問",[100,18096,18097],{"id":18097},"進行步驟",[211,18099,18100,18106,18112,18118,18124,18130],{},[69,18101,18102,18105],{},[23,18103,18104],{},"選一個具體的情境","：「一位買方看了物件後出價」，而不是「斡旋流程」。具體的情境才講得出具體的步驟",[69,18107,18108,18111],{},[23,18109,18110],{},"從觸發點開始講","：「這件事是怎麼開始的？」",[69,18113,18114,18117],{},[23,18115,18116],{},"一句一句畫","：每講一步就畫一步，畫完重述，對方確認後再講下一步",[69,18119,18120,18123],{},[23,18121,18122],{},"遇到分支先記下來","：「如果屋主不接受呢？」先寫在註解上，等這個故事講完，再另外畫一個「屋主還價」的故事",[69,18125,18126,18129],{},[23,18127,18128],{},"整個故事重講一遍","：照著序號從頭講一次，常常會在這時發現漏掉的步驟",[69,18131,18132,18135],{},[23,18133,18134],{},"標出疑問和痛點","：例如「經紀人要手動通知所有斡旋人，常常漏掉」",[11,18137,18138,18139,18144],{},"一個故事通常 30 分鐘到 1 小時就能畫完。工具可以用白板，或是免費的線上工具 ",[119,18140,18143],{"href":18141,"rel":18142},"https:\u002F\u002Fegon.io\u002F",[3057],"Egon.io","，它內建 Domain Storytelling 的圖示，還可以逐步回放故事。",[100,18146,18147],{"id":18147},"不要畫分支",[11,18149,18150,18151,18154],{},"這是我第一次畫時最常犯的錯。流程圖習慣畫「判斷」菱形，但 Domain Storytelling 刻意不畫分支：",[23,18152,18153],{},"一個故事只講一種情況","。屋主接受、屋主還價、斡旋過期，是三個不同的故事。",[11,18156,18157],{},"這樣做的好處是每張圖都很好讀，而且逼大家用具體的例子思考。要說清楚的情況越多，就畫越多張，而不是把一張圖畫成迷宮。",[28,18159,18161],{"id":18160},"範圍因子先決定要畫哪一種故事","範圍因子：先決定要畫哪一種故事",[11,18163,18164],{},"開始之前，要先和參與者確認這次故事的「範圍」，書中整理了三個因子：",[11,18166,18167],{},[1952,18168],{"alt":18169,"src":18170},"Domain Storytelling 的三個範圍因子：第一是粒度，粗粒度描述從找房到成交的整個過程，大約十步，用來看全貌和找邊界，細粒度只描述斡旋這一段的每個細節，用來釐清規則和需求；第二是時間點，現況描述目前實際怎麼做，例如經紀人手動打電話通知所有斡旋人，未來描述導入新系統後要怎麼做，例如系統自動通知；第三是領域純度，純領域只講業務，不提任何軟體，例如經紀人記下買方反應，數位化則畫出系統扮演的角色，例如經紀人在業務系統填寫帶看紀錄","\u002Fimages\u002Farticles\u002Fdomain-storytelling-scope.svg",[132,18172,18173,18186],{},[135,18174,18175],{},[138,18176,18177,18180,18183],{},[141,18178,18179],{},"因子",[141,18181,18182],{},"選項",[141,18184,18185],{},"用途",[152,18187,18188,18199,18210],{},[138,18189,18190,18193,18196],{},[157,18191,18192],{},"粒度（Granularity）",[157,18194,18195],{},"粗粒度／細粒度",[157,18197,18198],{},"粗粒度看全貌、找上下文邊界；細粒度釐清單一流程的規則",[138,18200,18201,18204,18207],{},[157,18202,18203],{},"時間點（Point in Time）",[157,18205,18206],{},"現況（As-is）／未來（To-be）",[157,18208,18209],{},"現況用來理解問題；未來用來討論新系統要怎麼改變工作方式",[138,18211,18212,18215,18218],{},[157,18213,18214],{},"領域純度（Domain Purity）",[157,18216,18217],{},"純領域（Pure）／數位化（Digitalized）",[157,18219,18220],{},"純領域只講業務，避免被現有系統限制；數位化畫出軟體扮演的角色",[11,18222,18223],{},"常見的組合是：",[66,18225,18226,18232],{},[69,18227,18228,18231],{},[23,18229,18230],{},"剛接觸一個領域","：粗粒度 + 現況 + 純領域，先理解業務本身",[69,18233,18234,18237],{},[23,18235,18236],{},"要設計新系統","：細粒度 + 未來 + 數位化，討論系統要怎麼支援每一步",[11,18239,18240],{},"上面帶看的例子是「粗粒度、現況、數位化」：它涵蓋整個過程，描述目前的做法，而且畫出了網站和業務系統。",[11,18242,18243,18244,18247],{},"範圍因子要",[23,18245,18246],{},"在開始前講好","。如果有人在講現況，有人在講「理想上應該要…」，故事會一直被打斷，最後兩邊都畫不完。",[28,18249,18250],{"id":18250},"從故事找出限界上下文",[11,18252,18253],{},"粗粒度的故事畫完後，可以在上面找出限界上下文的候選邊界。幾個常用的線索：",[66,18255,18256,18262,18268,18274],{},[69,18257,18258,18261],{},[23,18259,18260],{},"語言改變了","：前半段說「物件」指的是網站上的刊登內容，到斡旋階段，「物件」變成有權狀、有所有權人的交易標的",[69,18263,18264,18267],{},[23,18265,18266],{},"工作物件換了","：步驟 2～5 圍繞著「帶看」和「帶看紀錄」，步驟 6～9 圍繞著「斡旋書」和「斡旋金」",[69,18269,18270,18273],{},[23,18271,18272],{},"有明顯的交接或時間間隔","：買方看完屋，可能隔好幾天才決定斡旋，前後兩段的觸發條件完全不同",[69,18275,18276,18279],{},[23,18277,18278],{},"負責的人或部門不同","：帶看是經紀人自己安排，斡旋涉及店長審核、斡旋金的保管，參與的人不一樣",[11,18281,18282,18283,18286,18287,18290,18291,18294],{},"把這些線索對照到序號上，就能把故事分成三段：步驟 1 是",[23,18284,18285],{},"搜尋","、步驟 2～5 是",[23,18288,18289],{},"帶看","、步驟 6～9 是",[23,18292,18293],{},"斡旋","，也就是範例圖中序號的三種顏色。這只是候選的邊界，還要再看業務的重要性、團隊分工等因素才會定案，這部分之後會另外整理。",[28,18296,18298],{"id":18297},"和-event-storming-的比較","和 Event Storming 的比較",[11,18300,18301],{},"兩者都是協作建模，但適合的情境不太一樣：",[132,18303,18304,18314],{},[135,18305,18306],{},[138,18307,18308,18310,18312],{},[141,18309],{},[141,18311,11082],{},[141,18313,8963],{},[152,18315,18316,18327,18338,18349,18360],{},[138,18317,18318,18321,18324],{},[157,18319,18320],{},"主軸",[157,18322,18323],{},"角色和工作物件，「誰對什麼做了什麼」",[157,18325,18326],{},"領域事件，「發生了什麼事」",[138,18328,18329,18332,18335],{},[157,18330,18331],{},"進行方式",[157,18333,18334],{},"一位主持人一邊聽一邊畫，一次講一個故事",[157,18336,18337],{},"所有人同時在牆上貼便利貼",[138,18339,18340,18343,18346],{},[157,18341,18342],{},"適合人數",[157,18344,18345],{},"少，3～6 人",[157,18347,18348],{},"多，可以 10 人以上",[138,18350,18351,18354,18357],{},[157,18352,18353],{},"擅長",[157,18355,18356],{},"深入一個流程、看出人和人之間的交接",[157,18358,18359],{},"一次看到整個業務的全貌，發現部門之間的落差",[138,18361,18362,18365,18368],{},[157,18363,18364],{},"門檻",[157,18366,18367],{},"低，像訪談",[157,18369,18370],{},"要先說明規則，主持需要經驗",[11,18372,18373,18374,18377],{},"我的做法是：",[23,18375,18376],{},"剛接觸一個領域時先用 Domain Storytelling 和一兩位專家訪談","，對業務有基本理解後，再找更多部門一起跑 Event Storming。兩者不衝突，用 Domain Storytelling 找到的角色和工作物件，在 Event Storming 時也很容易轉成事件和命令。",[28,18379,4494],{"id":4494},[66,18381,18382,18388,18394,18400,18406],{},[69,18383,18384,18387],{},[23,18385,18386],{},"用具體的例子","：「王先生上週六看了信義區那間三房」比「使用者瀏覽物件」好講得多，也更容易發現例外",[69,18389,18390,18393],{},[23,18391,18392],{},"不要畫成畫面流程","：「點擊預約按鈕、跳出日曆」是 UI 設計，不是業務。即使是數位化的故事，也只畫「經紀人在業務系統填寫帶看紀錄」",[69,18395,18396,18399],{},[23,18397,18398],{},"建模者不要自己補內容","：覺得少了一步就問，不要自己畫上去。建模者的假設常常就是需求誤解的來源",[69,18401,18402,18405],{},[23,18403,18404],{},"一張圖不要太長","：步驟超過 20 步就很難讀，可以把細節拆成另一個細粒度的故事",[69,18407,18408,18411],{},[23,18409,18410],{},"畫完要保存","：拍照或匯出，放進團隊的文件。之後新成員要理解業務時，這些故事比任何規格書都好懂",[28,18413,1890],{"id":1890},[11,18415,18416],{},"Domain Storytelling 用「角色、活動、工作物件」的簡單句子，讓領域專家照著真實的工作講故事、主持人一邊畫一邊確認；一個故事只講一種情況，開始前先決定粒度、時間點與領域純度，畫完後再從語言、工作物件與交接點的變化，找出限界上下文的候選邊界。",{"title":44,"searchDepth":386,"depth":386,"links":18418},[18419,18420,18421,18426,18427,18428,18429,18430],{"id":17925,"depth":386,"text":17926},{"id":18034,"depth":386,"text":18035},{"id":18057,"depth":386,"text":18057,"children":18422},[18423,18424,18425],{"id":18060,"depth":397,"text":18060},{"id":18097,"depth":397,"text":18097},{"id":18147,"depth":397,"text":18147},{"id":18160,"depth":386,"text":18161},{"id":18250,"depth":386,"text":18250},{"id":18297,"depth":386,"text":18298},{"id":4494,"depth":386,"text":4494},{"id":1890,"depth":386,"text":1890},"2024-03-19","在 DDD 概論 提到，DDD 的前提是真的理解業務，而理解業務的方法之一是 Event Storming。不過實際和業務同事坐下來時，我發現 Event Storming 對第一次參加的人有點門檻：要先解釋什麼是「領域事件」、為什麼要用過去式、橘色和藍色差在哪裡。",{},{"title":17907,"description":18432},"articles\u002Fdomain-storytelling",[8964,11785,11082,8965,3037],"Kczi9D4cxItwNOPZ0YUVxZECeMYiU80LLUgTj9Qxit0",{"id":18439,"title":18440,"body":18441,"date":19833,"description":44,"excerpt":1156,"extension":1157,"meta":19834,"navigation":1035,"path":19835,"seo":19836,"stem":19837,"tags":19838,"__hash__":19842},"articles\u002Farticles\u002Fdotnet-reflection-generic-handler-registration.md","用反射自動註冊泛型 Handler：找出所有 IEventHandler\u003CT>，再在執行時依事件型別派送",{"type":8,"value":18442,"toc":19823},[18443,18447,18450,18563,18573,18628,18635,18646,18654,18660,18895,18898,18967,18985,18992,19022,19026,19029,19146,19159,19335,19343,19354,19358,19365,19381,19703,19706,19709,19765,19769,19772,19797,19800,19802,19820],[28,18444,18446],{"id":18445},"問題手動註冊一定會漏","問題：手動註冊一定會漏",[11,18448,18449],{},"做事件匯流排時，訂閱者的寫法通常是實作一個泛型介面：",[36,18451,18453],{"className":3145,"code":18452,"language":3147,"meta":44,"style":44},"public interface IEventHandler\u003Cin TEvent>\n{\n    Task HandleAsync(TEvent @event, CancellationToken cancellationToken);\n}\n\npublic class OrderCreatedHandler : IEventHandler\u003COrderCreated> { \u002F* ... *\u002F }\npublic class SendWelcomeMailHandler : IEventHandler\u003CMemberRegistered> { \u002F* ... *\u002F }\n",[15,18454,18455,18476,18480,18504,18508,18512,18539],{"__ignoreMap":44},[376,18456,18457,18459,18462,18465,18467,18470,18473],{"class":378,"line":379},[376,18458,3274],{"class":3196},[376,18460,18461],{"class":3196}," interface",[376,18463,18464],{"class":1020}," IEventHandler",[376,18466,3176],{"class":393},[376,18468,18469],{"class":3196},"in",[376,18471,18472],{"class":1020}," TEvent",[376,18474,18475],{"class":393},">\n",[376,18477,18478],{"class":378,"line":386},[376,18479,3324],{"class":393},[376,18481,18482,18485,18488,18490,18493,18496,18498,18500,18502],{"class":378,"line":397},[376,18483,18484],{"class":1020},"    Task",[376,18486,18487],{"class":1020}," HandleAsync",[376,18489,3220],{"class":393},[376,18491,18492],{"class":1020},"TEvent",[376,18494,18495],{"class":1020}," @event",[376,18497,3188],{"class":393},[376,18499,3307],{"class":1020},[376,18501,3310],{"class":1020},[376,18503,3376],{"class":393},[376,18505,18506],{"class":378,"line":405},[376,18507,3381],{"class":393},[376,18509,18510],{"class":378,"line":418},[376,18511,1036],{"emptyLinePlaceholder":1035},[376,18513,18514,18516,18518,18521,18524,18527,18529,18532,18535,18537],{"class":378,"line":429},[376,18515,3274],{"class":3196},[376,18517,12180],{"class":3196},[376,18519,18520],{"class":1020}," OrderCreatedHandler",[376,18522,18523],{"class":393}," : ",[376,18525,18526],{"class":1020},"IEventHandler",[376,18528,3176],{"class":393},[376,18530,18531],{"class":1020},"OrderCreated",[376,18533,18534],{"class":393},"> { ",[376,18536,12090],{"class":382},[376,18538,3610],{"class":393},[376,18540,18541,18543,18545,18548,18550,18552,18554,18557,18559,18561],{"class":378,"line":437},[376,18542,3274],{"class":3196},[376,18544,12180],{"class":3196},[376,18546,18547],{"class":1020}," SendWelcomeMailHandler",[376,18549,18523],{"class":393},[376,18551,18526],{"class":1020},[376,18553,3176],{"class":393},[376,18555,18556],{"class":1020},"MemberRegistered",[376,18558,18534],{"class":393},[376,18560,12090],{"class":382},[376,18562,3610],{"class":393},[11,18564,18565,18566,18568,18569,18572],{},"接著要讓框架知道「",[15,18567,18531],{}," 事件要交給 ",[15,18570,18571],{},"OrderCreatedHandler","」，最直接的方式是手動註冊：",[36,18574,18576],{"className":3145,"code":18575,"language":3147,"meta":44,"style":44},"services.AddScoped\u003CIEventHandler\u003COrderCreated>, OrderCreatedHandler>();\nservices.AddScoped\u003CIEventHandler\u003CMemberRegistered>, SendWelcomeMailHandler>();\n\u002F\u002F ……第 30 個 Handler\n",[15,18577,18578,18602,18623],{"__ignoreMap":44},[376,18579,18580,18583,18586,18588,18590,18592,18594,18597,18599],{"class":378,"line":379},[376,18581,18582],{"class":393},"services.",[376,18584,18585],{"class":1020},"AddScoped",[376,18587,3176],{"class":393},[376,18589,18526],{"class":1020},[376,18591,3176],{"class":393},[376,18593,18531],{"class":1020},[376,18595,18596],{"class":393},">, ",[376,18598,18571],{"class":1020},[376,18600,18601],{"class":393},">();\n",[376,18603,18604,18606,18608,18610,18612,18614,18616,18618,18621],{"class":378,"line":386},[376,18605,18582],{"class":393},[376,18607,18585],{"class":1020},[376,18609,3176],{"class":393},[376,18611,18526],{"class":1020},[376,18613,3176],{"class":393},[376,18615,18556],{"class":1020},[376,18617,18596],{"class":393},[376,18619,18620],{"class":1020},"SendWelcomeMailHandler",[376,18622,18601],{"class":393},[376,18624,18625],{"class":378,"line":397},[376,18626,18627],{"class":382},"\u002F\u002F ……第 30 個 Handler\n",[11,18629,18630,18631,18634],{},"Handler 一多，就一定會有人寫好了新的 Handler、卻忘了註冊。更麻煩的是，",[23,18632,18633],{},"忘記註冊不會有任何錯誤","：事件照常發布，只是沒有人處理，要到資料對不起來時才會發現。",[11,18636,18637,18638,18645],{},"比較好的做法是：",[23,18639,18640,18641,18644],{},"只要實作了 ",[15,18642,18643],{},"IEventHandler\u003CT>","，就自動被註冊","。這需要用到反射。",[28,18647,18649,18650],{"id":18648},"掃描找出所有-ieventhandler-的實作","掃描：找出所有 IEventHandler",[18651,18652,18653],"t",{}," 的實作",[11,18655,18656],{},[1952,18657],{"alt":18658,"src":18659},"反射註冊流程示意圖：① 以 assembly.GetTypes 列出組件中的型別，只留下非抽象的類別，排除不是 Handler 的類別、抽象類別與介面本身；② 以 GetInterfaces 找出 IsGenericType 且泛型定義等於 IEventHandler 的介面，NotifyHandler 同時實作了兩個；③ 以 GetGenericArguments 取出事件型別；④ 登記事件型別對應 Handler 的對照表，並註冊進 DI。反射只在應用程式啟動時跑一次，之後發布事件時是查表加上從 DI 取出 Handler","\u002Fimages\u002Farticles\u002Freflection-handler-registration.svg",[36,18661,18663],{"className":3145,"code":18662,"language":3147,"meta":44,"style":44},"public static IServiceCollection AddEventHandlersFromAssembly(\n    this IServiceCollection services, Assembly assembly)\n{\n    var registrations =\n        from type in assembly.GetTypes()\n        where type is { IsClass: true, IsAbstract: false, IsGenericTypeDefinition: false }\n        from @interface in type.GetInterfaces()\n        where @interface.IsGenericType\n           && @interface.GetGenericTypeDefinition() == typeof(IEventHandler\u003C>)\n        select (ServiceType: @interface, ImplementationType: type);\n\n    foreach (var (serviceType, implementationType) in registrations)\n    {\n        \u002F\u002F 例如：IEventHandler\u003COrderCreated> → OrderCreatedHandler\n        services.AddScoped(serviceType, implementationType);\n    }\n\n    return services;\n}\n",[15,18664,18665,18679,18699,18703,18713,18732,18760,18778,18783,18808,18827,18831,18857,18861,18866,18876,18880,18884,18891],{"__ignoreMap":44},[376,18666,18667,18669,18671,18674,18677],{"class":378,"line":379},[376,18668,3274],{"class":3196},[376,18670,3411],{"class":3196},[376,18672,18673],{"class":1020}," IServiceCollection",[376,18675,18676],{"class":1020}," AddEventHandlersFromAssembly",[376,18678,3294],{"class":393},[376,18680,18681,18684,18686,18689,18691,18694,18697],{"class":378,"line":386},[376,18682,18683],{"class":3196},"    this",[376,18685,18673],{"class":1020},[376,18687,18688],{"class":1020}," services",[376,18690,3188],{"class":393},[376,18692,18693],{"class":1020},"Assembly",[376,18695,18696],{"class":1020}," assembly",[376,18698,3319],{"class":393},[376,18700,18701],{"class":378,"line":397},[376,18702,3324],{"class":393},[376,18704,18705,18707,18710],{"class":378,"line":405},[376,18706,5061],{"class":3196},[376,18708,18709],{"class":1020}," registrations",[376,18711,18712],{"class":3196}," =\n",[376,18714,18715,18718,18721,18724,18727,18730],{"class":378,"line":418},[376,18716,18717],{"class":3196},"        from",[376,18719,18720],{"class":1020}," type",[376,18722,18723],{"class":3196}," in",[376,18725,18726],{"class":393}," assembly.",[376,18728,18729],{"class":1020},"GetTypes",[376,18731,3165],{"class":393},[376,18733,18734,18737,18740,18742,18745,18748,18751,18753,18756,18758],{"class":378,"line":429},[376,18735,18736],{"class":3196},"        where",[376,18738,18739],{"class":393}," type ",[376,18741,12611],{"class":3196},[376,18743,18744],{"class":393}," { IsClass: ",[376,18746,18747],{"class":560},"true",[376,18749,18750],{"class":393},", IsAbstract: ",[376,18752,5575],{"class":560},[376,18754,18755],{"class":393},", IsGenericTypeDefinition: ",[376,18757,5575],{"class":560},[376,18759,3610],{"class":393},[376,18761,18762,18764,18767,18769,18771,18773,18776],{"class":378,"line":437},[376,18763,18717],{"class":1020},[376,18765,18766],{"class":1020}," @interface",[376,18768,18723],{"class":1020},[376,18770,18720],{"class":1020},[376,18772,4735],{"class":393},[376,18774,18775],{"class":1020},"GetInterfaces",[376,18777,3165],{"class":393},[376,18779,18780],{"class":378,"line":447},[376,18781,18782],{"class":393},"        where @interface.IsGenericType\n",[376,18784,18785,18788,18791,18794,18796,18798,18801,18803,18805],{"class":378,"line":531},[376,18786,18787],{"class":3196},"           &&",[376,18789,18790],{"class":393}," @interface.",[376,18792,18793],{"class":1020},"GetGenericTypeDefinition",[376,18795,5104],{"class":393},[376,18797,13221],{"class":3196},[376,18799,18800],{"class":3196}," typeof",[376,18802,3220],{"class":393},[376,18804,18526],{"class":1020},[376,18806,18807],{"class":393},"\u003C>)\n",[376,18809,18810,18813,18815,18818,18821,18824],{"class":378,"line":542},[376,18811,18812],{"class":1020},"        select",[376,18814,5046],{"class":393},[376,18816,18817],{"class":1020},"ServiceType",[376,18819,18820],{"class":393},": @interface, ",[376,18822,18823],{"class":1020},"ImplementationType",[376,18825,18826],{"class":393},": type);\n",[376,18828,18829],{"class":378,"line":552},[376,18830,1036],{"emptyLinePlaceholder":1035},[376,18832,18833,18836,18838,18840,18842,18845,18847,18850,18852,18854],{"class":378,"line":564},[376,18834,18835],{"class":3196},"    foreach",[376,18837,5046],{"class":393},[376,18839,4684],{"class":3196},[376,18841,5046],{"class":393},[376,18843,18844],{"class":1020},"serviceType",[376,18846,3188],{"class":393},[376,18848,18849],{"class":1020},"implementationType",[376,18851,5662],{"class":393},[376,18853,18469],{"class":3196},[376,18855,18856],{"class":393}," registrations)\n",[376,18858,18859],{"class":378,"line":575},[376,18860,5704],{"class":393},[376,18862,18863],{"class":378,"line":583},[376,18864,18865],{"class":382},"        \u002F\u002F 例如：IEventHandler\u003COrderCreated> → OrderCreatedHandler\n",[376,18867,18868,18871,18873],{"class":378,"line":597},[376,18869,18870],{"class":393},"        services.",[376,18872,18585],{"class":1020},[376,18874,18875],{"class":393},"(serviceType, implementationType);\n",[376,18877,18878],{"class":378,"line":605},[376,18879,5766],{"class":393},[376,18881,18882],{"class":378,"line":616},[376,18883,1036],{"emptyLinePlaceholder":1035},[376,18885,18886,18888],{"class":378,"line":624},[376,18887,3362],{"class":3196},[376,18889,18890],{"class":393}," services;\n",[376,18892,18893],{"class":378,"line":635},[376,18894,3381],{"class":393},[11,18896,18897],{},"逐步拆解：",[211,18899,18900,18908,18929,18941,18961],{},[69,18901,18902,18907],{},[23,18903,18904],{},[15,18905,18906],{},"assembly.GetTypes()","：列出組件中的所有型別",[69,18909,18910,55,18913,18916,18917,18920,18921,18924,18925,18928],{},[23,18911,18912],{},"篩選可以被建立的類別",[15,18914,18915],{},"IsClass"," 排除介面與結構，",[15,18918,18919],{},"IsAbstract"," 排除抽象類別，",[15,18922,18923],{},"IsGenericTypeDefinition"," 排除還沒指定型別參數的開放泛型類別（例如 ",[15,18926,18927],{},"LoggingHandler\u003CT>","，它沒辦法直接被建立）",[69,18930,18931,18936,18937,18940],{},[23,18932,18933],{},[15,18934,18935],{},"GetInterfaces()","：取得這個類別實作的",[23,18938,18939],{},"所有","介面，包含從父類別繼承來的",[69,18942,18943,55,18946,18949,18950,18953,18954,18957,18958],{},[23,18944,18945],{},"比對泛型定義",[15,18947,18948],{},"IEventHandler\u003COrderCreated>"," 是一個「封閉泛型型別」，它的 ",[15,18951,18952],{},"GetGenericTypeDefinition()"," 是「開放泛型型別」",[15,18955,18956],{},"IEventHandler\u003C>","。用開放泛型比對，才能找出所有的 ",[15,18959,18960],{},"IEventHandler\u003C任何型別>",[69,18962,18963,18966],{},[23,18964,18965],{},"註冊","：以封閉泛型介面為服務型別、Handler 類別為實作型別註冊進 DI",[11,18968,18969,18970,18972,18973,8759,18975,17363,18978,18981,18982,18984],{},"一個類別實作了好幾個 ",[15,18971,18643],{},"（例如同時處理 ",[15,18974,18531],{},[15,18976,18977],{},"PaymentPaid",[15,18979,18980],{},"NotifyHandler","），",[15,18983,18935],{}," 會回傳兩個介面，兩個都會被註冊。",[11,18986,18987,18988,18991],{},"如果需要事件型別本身（例如要建立「事件型別 → Handler 型別」的對照表，或是要用事件型別名稱訂閱 RabbitMQ 的 Routing Key），可以用 ",[15,18989,18990],{},"GetGenericArguments()[0]"," 取出：",[36,18993,18995],{"className":3145,"code":18994,"language":3147,"meta":44,"style":44},"var eventType = @interface.GetGenericArguments()[0];   \u002F\u002F typeof(OrderCreated)\n",[15,18996,18997],{"__ignoreMap":44},[376,18998,18999,19001,19004,19006,19008,19011,19014,19016,19019],{"class":378,"line":379},[376,19000,4684],{"class":3196},[376,19002,19003],{"class":1020}," eventType",[376,19005,3313],{"class":3196},[376,19007,18790],{"class":393},[376,19009,19010],{"class":1020},"GetGenericArguments",[376,19012,19013],{"class":393},"()[",[376,19015,4975],{"class":560},[376,19017,19018],{"class":393},"];   ",[376,19020,19021],{"class":382},"\u002F\u002F typeof(OrderCreated)\n",[28,19023,19025],{"id":19024},"派送執行時只知道-object怎麼找到-handler","派送：執行時只知道 object，怎麼找到 Handler？",[11,19027,19028],{},"註冊好了，接下來是發布事件。如果發布端在編譯時就知道事件型別，直接用泛型即可：",[36,19030,19032],{"className":3145,"code":19031,"language":3147,"meta":44,"style":44},"public async Task PublishAsync\u003CTEvent>(TEvent @event, CancellationToken ct)\n{\n    using var scope = _scopeFactory.CreateScope();\n    var handlers = scope.ServiceProvider.GetServices\u003CIEventHandler\u003CTEvent>>();\n    foreach (var handler in handlers)\n        await handler.HandleAsync(@event, ct);\n}\n",[15,19033,19034,19064,19068,19087,19113,19129,19142],{"__ignoreMap":44},[376,19035,19036,19038,19040,19042,19045,19047,19049,19051,19053,19055,19057,19059,19062],{"class":378,"line":379},[376,19037,3274],{"class":3196},[376,19039,3277],{"class":3196},[376,19041,3280],{"class":1020},[376,19043,19044],{"class":1020}," PublishAsync",[376,19046,3176],{"class":393},[376,19048,18492],{"class":1020},[376,19050,3182],{"class":393},[376,19052,18492],{"class":1020},[376,19054,18495],{"class":1020},[376,19056,3188],{"class":393},[376,19058,3307],{"class":1020},[376,19060,19061],{"class":1020}," ct",[376,19063,3319],{"class":393},[376,19065,19066],{"class":378,"line":386},[376,19067,3324],{"class":393},[376,19069,19070,19072,19074,19077,19079,19082,19085],{"class":378,"line":397},[376,19071,9419],{"class":3196},[376,19073,9422],{"class":3196},[376,19075,19076],{"class":1020}," scope",[376,19078,3313],{"class":3196},[376,19080,19081],{"class":393}," _scopeFactory.",[376,19083,19084],{"class":1020},"CreateScope",[376,19086,3532],{"class":393},[376,19088,19089,19091,19094,19096,19099,19102,19104,19106,19108,19110],{"class":378,"line":405},[376,19090,5061],{"class":3196},[376,19092,19093],{"class":1020}," handlers",[376,19095,3313],{"class":3196},[376,19097,19098],{"class":393}," scope.ServiceProvider.",[376,19100,19101],{"class":1020},"GetServices",[376,19103,3176],{"class":393},[376,19105,18526],{"class":1020},[376,19107,3176],{"class":393},[376,19109,18492],{"class":1020},[376,19111,19112],{"class":393},">>();\n",[376,19114,19115,19117,19119,19121,19124,19126],{"class":378,"line":418},[376,19116,18835],{"class":3196},[376,19118,5046],{"class":393},[376,19120,4684],{"class":3196},[376,19122,19123],{"class":1020}," handler",[376,19125,18723],{"class":3196},[376,19127,19128],{"class":393}," handlers)\n",[376,19130,19131,19133,19136,19139],{"class":378,"line":429},[376,19132,9572],{"class":3196},[376,19134,19135],{"class":393}," handler.",[376,19137,19138],{"class":1020},"HandleAsync",[376,19140,19141],{"class":393},"(@event, ct);\n",[376,19143,19144],{"class":378,"line":437},[376,19145,3381],{"class":393},[11,19147,19148,19149,19152,19153,19155,19156,55],{},"但從訊息佇列收到的訊息，反序列化之後只是一個 ",[15,19150,19151],{},"object","（事件型別是從訊息標頭裡的型別名稱解析出來的），編譯時並不知道是 ",[15,19154,18531],{},"。這時就要在",[23,19157,19158],{},"執行時組出泛型型別",[36,19160,19162],{"className":3145,"code":19161,"language":3147,"meta":44,"style":44},"public async Task DispatchAsync(object @event, CancellationToken ct)\n{\n    var handlerType = typeof(IEventHandler\u003C>).MakeGenericType(@event.GetType());\n    \u002F\u002F                 IEventHandler\u003C>  +  OrderCreated  →  IEventHandler\u003COrderCreated>\n\n    using var scope = _scopeFactory.CreateScope();\n    foreach (var handler in scope.ServiceProvider.GetServices(handlerType))\n    {\n        var method = handlerType.GetMethod(nameof(IEventHandler\u003Cobject>.HandleAsync))!;\n        await (Task)method.Invoke(handler, new[] { @event, ct })!;\n    }\n}\n",[15,19163,19164,19189,19193,19222,19227,19231,19247,19266,19270,19301,19327,19331],{"__ignoreMap":44},[376,19165,19166,19168,19170,19172,19175,19177,19179,19181,19183,19185,19187],{"class":378,"line":379},[376,19167,3274],{"class":3196},[376,19169,3277],{"class":3196},[376,19171,3280],{"class":1020},[376,19173,19174],{"class":1020}," DispatchAsync",[376,19176,3220],{"class":393},[376,19178,19151],{"class":3196},[376,19180,18495],{"class":1020},[376,19182,3188],{"class":393},[376,19184,3307],{"class":1020},[376,19186,19061],{"class":1020},[376,19188,3319],{"class":393},[376,19190,19191],{"class":378,"line":386},[376,19192,3324],{"class":393},[376,19194,19195,19197,19200,19202,19204,19206,19208,19211,19214,19217,19220],{"class":378,"line":397},[376,19196,5061],{"class":3196},[376,19198,19199],{"class":1020}," handlerType",[376,19201,3313],{"class":3196},[376,19203,18800],{"class":3196},[376,19205,3220],{"class":393},[376,19207,18526],{"class":1020},[376,19209,19210],{"class":393},"\u003C>).",[376,19212,19213],{"class":1020},"MakeGenericType",[376,19215,19216],{"class":393},"(@event.",[376,19218,19219],{"class":1020},"GetType",[376,19221,12018],{"class":393},[376,19223,19224],{"class":378,"line":405},[376,19225,19226],{"class":382},"    \u002F\u002F                 IEventHandler\u003C>  +  OrderCreated  →  IEventHandler\u003COrderCreated>\n",[376,19228,19229],{"class":378,"line":418},[376,19230,1036],{"emptyLinePlaceholder":1035},[376,19232,19233,19235,19237,19239,19241,19243,19245],{"class":378,"line":429},[376,19234,9419],{"class":3196},[376,19236,9422],{"class":3196},[376,19238,19076],{"class":1020},[376,19240,3313],{"class":3196},[376,19242,19081],{"class":393},[376,19244,19084],{"class":1020},[376,19246,3532],{"class":393},[376,19248,19249,19251,19253,19255,19257,19259,19261,19263],{"class":378,"line":437},[376,19250,18835],{"class":3196},[376,19252,5046],{"class":393},[376,19254,4684],{"class":3196},[376,19256,19123],{"class":1020},[376,19258,18723],{"class":3196},[376,19260,19098],{"class":393},[376,19262,19101],{"class":1020},[376,19264,19265],{"class":393},"(handlerType))\n",[376,19267,19268],{"class":378,"line":447},[376,19269,5704],{"class":393},[376,19271,19272,19274,19277,19279,19282,19285,19287,19289,19292,19294,19297,19299],{"class":378,"line":531},[376,19273,13379],{"class":3196},[376,19275,19276],{"class":1020}," method",[376,19278,3313],{"class":3196},[376,19280,19281],{"class":393}," handlerType.",[376,19283,19284],{"class":1020},"GetMethod",[376,19286,3220],{"class":393},[376,19288,12314],{"class":3196},[376,19290,19291],{"class":393},"(IEventHandler\u003C",[376,19293,19151],{"class":3196},[376,19295,19296],{"class":393},">.HandleAsync))",[376,19298,5049],{"class":3196},[376,19300,3428],{"class":393},[376,19302,19303,19305,19307,19309,19312,19315,19318,19320,19323,19325],{"class":378,"line":542},[376,19304,9572],{"class":3196},[376,19306,5046],{"class":393},[376,19308,9295],{"class":1020},[376,19310,19311],{"class":393},")method.",[376,19313,19314],{"class":1020},"Invoke",[376,19316,19317],{"class":393},"(handler, ",[376,19319,3197],{"class":3196},[376,19321,19322],{"class":393},"[] { @event, ct })",[376,19324,5049],{"class":3196},[376,19326,3428],{"class":393},[376,19328,19329],{"class":378,"line":552},[376,19330,5766],{"class":393},[376,19332,19333],{"class":378,"line":564},[376,19334,3381],{"class":393},[11,19336,19337,19339,19340,19342],{},[15,19338,19213],{}," 是 ",[15,19341,18793],{}," 的反向操作：把開放泛型填上型別參數，變成封閉泛型。",[11,19344,19345,19346,19349,19350,19353],{},"每一個事件都",[23,19347,19348],{},"建立一個新的 DI Scope","，Handler 如果注入了 ",[15,19351,19352],{},"DbContext"," 這類 Scoped 服務，每個事件都會拿到獨立的實例，不會互相干擾。",[28,19355,19357],{"id":19356},"效能反射放在啟動時執行時快取","效能：反射放在啟動時，執行時快取",[11,19359,19360,19361,19364],{},"反射的成本主要在「找」：列舉組件中的所有型別、取得介面、查詢方法。這些動作放在",[23,19362,19363],{},"應用程式啟動時跑一次","完全沒問題，但不應該在每次發布事件時重做。",[11,19366,19367,19368,19371,19372,3860,19374,19376,19377,19380],{},"上面的 ",[15,19369,19370],{},"DispatchAsync"," 每次都呼叫 ",[15,19373,19213],{},[15,19375,19284],{},"，事件量大時可以依事件型別快取起來，並把 ",[15,19378,19379],{},"MethodInfo.Invoke"," 換成編譯好的委派：",[36,19382,19384],{"className":3145,"code":19383,"language":3147,"meta":44,"style":44},"private static readonly ConcurrentDictionary\u003CType, Func\u003Cobject, object, CancellationToken, Task>> Invokers = new();\n\nprivate static Func\u003Cobject, object, CancellationToken, Task> GetInvoker(Type eventType) =>\n    Invokers.GetOrAdd(eventType, static t =>\n    {\n        var handlerType = typeof(IEventHandler\u003C>).MakeGenericType(t);\n        var method = handlerType.GetMethod(\"HandleAsync\")!;\n\n        var handler = Expression.Parameter(typeof(object));\n        var @event  = Expression.Parameter(typeof(object));\n        var ct      = Expression.Parameter(typeof(CancellationToken));\n\n        var call = Expression.Call(\n            Expression.Convert(handler, handlerType), method,\n            Expression.Convert(@event, t), ct);\n\n        return Expression.Lambda\u003CFunc\u003Cobject, object, CancellationToken, Task>>(call, handler, @event, ct).Compile();\n    });\n",[15,19385,19386,19433,19437,19477,19495,19499,19520,19543,19547,19572,19595,19618,19622,19638,19649,19658,19662,19699],{"__ignoreMap":44},[376,19387,19388,19390,19392,19394,19396,19398,19401,19403,19405,19407,19409,19411,19413,19415,19417,19419,19421,19424,19427,19429,19431],{"class":378,"line":379},[376,19389,13062],{"class":3196},[376,19391,3411],{"class":3196},[376,19393,12197],{"class":3196},[376,19395,13069],{"class":1020},[376,19397,3176],{"class":393},[376,19399,19400],{"class":1020},"Type",[376,19402,3188],{"class":393},[376,19404,9286],{"class":1020},[376,19406,3176],{"class":393},[376,19408,19151],{"class":3196},[376,19410,3188],{"class":393},[376,19412,19151],{"class":3196},[376,19414,3188],{"class":393},[376,19416,3307],{"class":1020},[376,19418,3188],{"class":393},[376,19420,9295],{"class":1020},[376,19422,19423],{"class":393},">> ",[376,19425,19426],{"class":1020},"Invokers",[376,19428,3313],{"class":3196},[376,19430,4692],{"class":3196},[376,19432,3532],{"class":393},[376,19434,19435],{"class":378,"line":386},[376,19436,1036],{"emptyLinePlaceholder":1035},[376,19438,19439,19441,19443,19446,19448,19450,19452,19454,19456,19458,19460,19462,19464,19467,19469,19471,19473,19475],{"class":378,"line":397},[376,19440,13062],{"class":3196},[376,19442,3411],{"class":3196},[376,19444,19445],{"class":1020}," Func",[376,19447,3176],{"class":393},[376,19449,19151],{"class":3196},[376,19451,3188],{"class":393},[376,19453,19151],{"class":3196},[376,19455,3188],{"class":393},[376,19457,3307],{"class":1020},[376,19459,3188],{"class":393},[376,19461,9295],{"class":1020},[376,19463,3288],{"class":393},[376,19465,19466],{"class":1020},"GetInvoker",[376,19468,3220],{"class":393},[376,19470,19400],{"class":1020},[376,19472,19003],{"class":1020},[376,19474,5662],{"class":393},[376,19476,3778],{"class":3196},[376,19478,19479,19482,19484,19487,19490,19493],{"class":378,"line":405},[376,19480,19481],{"class":393},"    Invokers.",[376,19483,13270],{"class":1020},[376,19485,19486],{"class":393},"(eventType, ",[376,19488,19489],{"class":3196},"static",[376,19491,19492],{"class":1020}," t",[376,19494,12037],{"class":3196},[376,19496,19497],{"class":378,"line":418},[376,19498,5704],{"class":393},[376,19500,19501,19503,19505,19507,19509,19511,19513,19515,19517],{"class":378,"line":429},[376,19502,13379],{"class":3196},[376,19504,19199],{"class":1020},[376,19506,3313],{"class":3196},[376,19508,18800],{"class":3196},[376,19510,3220],{"class":393},[376,19512,18526],{"class":1020},[376,19514,19210],{"class":393},[376,19516,19213],{"class":1020},[376,19518,19519],{"class":393},"(t);\n",[376,19521,19522,19524,19526,19528,19530,19532,19534,19537,19539,19541],{"class":378,"line":437},[376,19523,13379],{"class":3196},[376,19525,19276],{"class":1020},[376,19527,3313],{"class":3196},[376,19529,19281],{"class":393},[376,19531,19284],{"class":1020},[376,19533,3220],{"class":393},[376,19535,19536],{"class":414},"\"HandleAsync\"",[376,19538,15026],{"class":393},[376,19540,5049],{"class":3196},[376,19542,3428],{"class":393},[376,19544,19545],{"class":378,"line":447},[376,19546,1036],{"emptyLinePlaceholder":1035},[376,19548,19549,19551,19553,19555,19558,19561,19563,19566,19568,19570],{"class":378,"line":531},[376,19550,13379],{"class":3196},[376,19552,19123],{"class":1020},[376,19554,3313],{"class":3196},[376,19556,19557],{"class":393}," Expression.",[376,19559,19560],{"class":1020},"Parameter",[376,19562,3220],{"class":393},[376,19564,19565],{"class":3196},"typeof",[376,19567,3220],{"class":393},[376,19569,19151],{"class":3196},[376,19571,3839],{"class":393},[376,19573,19574,19576,19578,19581,19583,19585,19587,19589,19591,19593],{"class":378,"line":542},[376,19575,13379],{"class":3196},[376,19577,18495],{"class":1020},[376,19579,19580],{"class":3196},"  =",[376,19582,19557],{"class":393},[376,19584,19560],{"class":1020},[376,19586,3220],{"class":393},[376,19588,19565],{"class":3196},[376,19590,3220],{"class":393},[376,19592,19151],{"class":3196},[376,19594,3839],{"class":393},[376,19596,19597,19599,19601,19604,19606,19608,19610,19612,19614,19616],{"class":378,"line":552},[376,19598,13379],{"class":3196},[376,19600,19061],{"class":1020},[376,19602,19603],{"class":3196},"      =",[376,19605,19557],{"class":393},[376,19607,19560],{"class":1020},[376,19609,3220],{"class":393},[376,19611,19565],{"class":3196},[376,19613,3220],{"class":393},[376,19615,3307],{"class":1020},[376,19617,3839],{"class":393},[376,19619,19620],{"class":378,"line":564},[376,19621,1036],{"emptyLinePlaceholder":1035},[376,19623,19624,19626,19629,19631,19633,19636],{"class":378,"line":575},[376,19625,13379],{"class":3196},[376,19627,19628],{"class":1020}," call",[376,19630,3313],{"class":3196},[376,19632,19557],{"class":393},[376,19634,19635],{"class":1020},"Call",[376,19637,3294],{"class":393},[376,19639,19640,19643,19646],{"class":378,"line":583},[376,19641,19642],{"class":393},"            Expression.",[376,19644,19645],{"class":1020},"Convert",[376,19647,19648],{"class":393},"(handler, handlerType), method,\n",[376,19650,19651,19653,19655],{"class":378,"line":597},[376,19652,19642],{"class":393},[376,19654,19645],{"class":1020},[376,19656,19657],{"class":393},"(@event, t), ct);\n",[376,19659,19660],{"class":378,"line":605},[376,19661,1036],{"emptyLinePlaceholder":1035},[376,19663,19664,19666,19668,19671,19673,19675,19677,19679,19681,19683,19685,19687,19689,19691,19694,19697],{"class":378,"line":616},[376,19665,3485],{"class":3196},[376,19667,19557],{"class":393},[376,19669,19670],{"class":1020},"Lambda",[376,19672,3176],{"class":393},[376,19674,9286],{"class":1020},[376,19676,3176],{"class":393},[376,19678,19151],{"class":3196},[376,19680,3188],{"class":393},[376,19682,19151],{"class":3196},[376,19684,3188],{"class":393},[376,19686,3307],{"class":1020},[376,19688,3188],{"class":393},[376,19690,9295],{"class":1020},[376,19692,19693],{"class":393},">>(call, handler, @event, ct).",[376,19695,19696],{"class":1020},"Compile",[376,19698,3532],{"class":393},[376,19700,19701],{"class":378,"line":624},[376,19702,9554],{"class":393},[11,19704,19705],{},"每個事件型別只會建立一次委派，之後的呼叫和一般的方法呼叫速度相當。",[28,19707,19708],{"id":19708},"實務上要注意的地方",[66,19710,19711,19726,19736,19753],{},[69,19712,19713,55,19716,19719,19720,2390,19723],{},[23,19714,19715],{},"掃描哪些組件",[15,19717,19718],{},"Assembly.GetExecutingAssembly()"," 指的是「目前執行中的程式碼所在的組件」——如果掃描的程式寫在函式庫裡，拿到的是函式庫本身，而不是使用者的應用程式。比較明確的做法是讓呼叫端傳入，例如 ",[15,19721,19722],{},"AddEventHandlersFromAssembly(typeof(Program).Assembly)",[15,19724,19725],{},"Assembly.GetEntryAssembly()",[69,19727,19728,19731,19732,19735],{},[23,19729,19730],{},"重複註冊","：同一個組件被掃描兩次，Handler 會被註冊兩次、事件被處理兩次。可以用 ",[15,19733,19734],{},"TryAddEnumerable"," 註冊，相同的服務與實作組合只會加入一次",[69,19737,19738,19744,19745,19748,19749,19752],{},[23,19739,19740,19743],{},[15,19741,19742],{},"GetTypes()"," 可能拋出例外","：組件中有型別的相依組件載入失敗時，會拋出 ",[15,19746,19747],{},"ReflectionTypeLoadException","，可以從例外的 ",[15,19750,19751],{},"Types"," 屬性取出成功載入的部分",[69,19754,19755,19758,19759,19764],{},[23,19756,19757],{},"現成的工具","：只是要「掃描並註冊」，",[119,19760,19763],{"href":19761,"rel":19762},"https:\u002F\u002Fgithub.com\u002Fkhellang\u002FScrutor",[3057],"Scrutor"," 這類套件已經做得很完整；在意啟動時間或要做 AOT 編譯的話，可以考慮用 Source Generator 在編譯時產生註冊程式碼，完全避開執行時的反射",[28,19766,19768],{"id":19767},"使用情境自製的-eventbus-函式庫","使用情境：自製的 EventBus 函式庫",[11,19770,19771],{},"自製的 .NET EventBus 函式庫就是用這套方式處理訂閱：",[66,19773,19774,19785,19794],{},[69,19775,19776,19777,19780,19781,19784],{},"比照 ",[15,19778,19779],{},"IServiceCollection","，另外設計一個 ",[15,19782,19783],{},"SubscriptionCollection","，記錄「事件型別 → Handler 型別」的對照",[69,19786,19787,19790,19791,19793],{},[15,19788,19789],{},"AddEventHandlersFromAssembly()"," 在啟動時以反射掃描組件，自動登記所有 ",[15,19792,18643],{},"，同時把 Handler 註冊進 DI",[69,19795,19796],{},"從記憶體佇列或 RabbitMQ 收到事件時，依事件型別查表，建立 DI Scope 取出 Handler 並呼叫",[11,19798,19799],{},"使用者新增事件處理器時，只需要實作介面，不需要再修改任何註冊程式碼。",[28,19801,1890],{"id":1890},[11,19803,9999,19804,19806,19807,19809,19810,19813,19814,19816,19817,19819],{},[15,19805,19742],{}," 加 ",[15,19808,18935],{},"，比對 ",[15,19811,19812],{},"GetGenericTypeDefinition() == typeof(IEventHandler\u003C>)","，就能在啟動時自動找出並註冊所有 Handler；執行時只知道 ",[15,19815,19151],{}," 的情況，用 ",[15,19818,19213],{}," 組出封閉泛型再從 DI 取出 Handler，並把反射的結果依型別快取成委派，讓反射只發生一次。",[1132,19821,19822],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":44,"searchDepth":386,"depth":386,"links":19824},[19825,19826,19828,19829,19830,19831,19832],{"id":18445,"depth":386,"text":18446},{"id":18648,"depth":386,"text":19827},"掃描：找出所有 IEventHandler 的實作",{"id":19024,"depth":386,"text":19025},{"id":19356,"depth":386,"text":19357},{"id":19708,"depth":386,"text":19708},{"id":19767,"depth":386,"text":19768},{"id":1890,"depth":386,"text":1890},"2024-02-27",{},"\u002Farticles\u002Fdotnet-reflection-generic-handler-registration",{"title":18440,"description":44},"articles\u002Fdotnet-reflection-generic-handler-registration",[10104,19839,19840,19841,4565],"C#","Reflection","Dependency Injection","n9LUr-XoGjYcKsXbb5oNKQc6gqcMtD7sJCbkvPnPdpQ",{"id":19844,"title":19845,"body":19846,"date":21711,"description":21712,"excerpt":1156,"extension":1157,"meta":21713,"navigation":1035,"path":8737,"seo":21714,"stem":21715,"tags":21716,"__hash__":21717},"articles\u002Farticles\u002Fddd-overview-strategic-tactical.md","DDD 概論：戰略設計、戰術設計與 Event Storming，先搞懂它到底在解決什麼問題",{"type":8,"value":19847,"toc":21693},[19848,19860,19863,19867,19873,19897,19903,19907,19913,19916,19966,19983,19985,19989,19995,19998,20056,20081,20084,20087,20093,20148,20154,20157,20163,20166,20186,20192,20199,20203,20206,20287,20294,20296,20303,20410,20414,20421,20456,20462,21237,21240,21271,21274,21511,21517,21530,21534,21541,21544,21547,21591,21598,21602,21605,21626,21630,21649,21656,21659,21685,21687,21690],[11,19849,17912,19850,19852,19853,19856,19857],{},[119,19851,16826],{"href":16825}," 那篇，我把業務規則都放進「核心」，外部技術變成可以抽換的 Adapter。但做完之後留下兩個問題：",[23,19854,19855],{},"核心裡面的模型要怎麼設計？"," 以及系統越長越大時，",[23,19858,19859],{},"一個核心要不要切成好幾個？要從哪裡切？",[11,19861,19862],{},"這兩個問題正好是 DDD（Domain-Driven Design，領域驅動設計）在回答的事。DDD 來自 Eric Evans 在 2003 年出版的同名書籍，二十年下來衍生出很多做法，這篇先把整體架構整理清楚，之後再針對子域劃分、Event Storming 這些主題分開深入。",[28,19864,19866],{"id":19865},"ddd-在解決什麼問題","DDD 在解決什麼問題",[11,19868,19869,19870,55],{},"做房仲系統這幾年，最常遇到的困難不是技術，而是",[23,19871,19872],{},"業務本身很複雜，而程式碼沒有反映出這份複雜",[66,19874,19875,19885,19891],{},[69,19876,19877,19880,19881,19884],{},[23,19878,19879],{},"翻譯成本","：業務說「這個物件已經有人斡旋了」，程式裡卻是 ",[15,19882,19883],{},"house.status = 3","。每次需求討論都要在業務語言和程式碼之間翻譯，翻錯一次就是一個 Bug",[69,19886,19887,19890],{},[23,19888,19889],{},"一個模型用到底","：「物件」一張資料表，刊登要用、搜尋要用、成交要用、業績也要用。欄位越長越多，改一個欄位要擔心十個地方",[69,19892,19893,19896],{},[23,19894,19895],{},"規則散落各處","：「斡旋過期就不能接受」這條規則，前端檢查一次、API 檢查一次、排程又檢查一次，三個地方的判斷還不太一樣",[11,19898,19899,19900],{},"DDD 的基本主張是：",[23,19901,19902],{},"軟體的核心複雜度來自業務領域本身，所以設計要從理解業務開始，讓程式碼的結構和用詞直接對應業務。",[28,19904,19906],{"id":19905},"全貌戰略戰術協作探索","全貌：戰略、戰術、協作探索",[11,19908,19909],{},[1952,19910],{"alt":19911,"src":19912},"DDD 的全貌：左邊是戰略設計，在問題空間把業務分成核心、支撐、通用子域，在解法空間把系統切成刊登、搜尋、成交等限界上下文，同一個「物件」在不同上下文有不同模型，上下文之間的關係用上下文映射描述；右邊是戰術設計，放大成交上下文，裡面由 Application Service 呼叫 Offer 聚合，聚合根 Offer 是實體，價格、斡旋金、期限是值物件，用 ListingId 參照其他聚合，狀態改變時發出 OfferAccepted 領域事件，透過 Repository 以聚合為單位存取；底部是協作探索，用 Event Storming 和 Domain Storytelling 和領域專家一起找出事件、語言與邊界","\u002Fimages\u002Farticles\u002Fddd-overview-map.svg",[11,19914,19915],{},"DDD 可以分成三個部分：",[132,19917,19918,19931],{},[135,19919,19920],{},[138,19921,19922,19925,19928],{},[141,19923,19924],{},"部分",[141,19926,19927],{},"回答的問題",[141,19929,19930],{},"主要概念",[152,19932,19933,19944,19955],{},[138,19934,19935,19938,19941],{},[157,19936,19937],{},"戰略設計",[157,19939,19940],{},"系統要切成哪幾塊？每塊多重要？塊和塊之間怎麼合作？",[157,19942,19943],{},"通用語言、子域、限界上下文、上下文映射",[138,19945,19946,19949,19952],{},[157,19947,19948],{},"戰術設計",[157,19950,19951],{},"一個上下文裡面，模型要怎麼寫？",[157,19953,19954],{},"聚合、實體、值物件、領域事件、Repository、領域服務",[138,19956,19957,19960,19963],{},[157,19958,19959],{},"協作探索",[157,19961,19962],{},"怎麼和領域專家一起把業務弄清楚？",[157,19964,19965],{},"Event Storming、Domain Storytelling",[11,19967,19968,19969,2276,19972,2276,19975,19978,19979,19982],{},"很多團隊導入 DDD 是從戰術設計開始，專案裡多了 ",[15,19970,19971],{},"Entities",[15,19973,19974],{},"ValueObjects",[15,19976,19977],{},"Repositories"," 資料夾，但系統還是一團。這種做法常被稱為「DDD-Lite」，只拿到最表面的好處。",[23,19980,19981],{},"戰略設計才是 DDD 影響最大的部分","：切錯了邊界，裡面的類別寫得再漂亮也沒用。",[28,19984,19937],{"id":19937},[100,19986,19988],{"id":19987},"通用語言ubiquitous-language","通用語言（Ubiquitous Language）",[11,19990,19991,19992,114],{},"團隊（包含業務、PO、開發人員）在討論、文件、程式碼中使用",[23,19993,19994],{},"同一套用詞",[11,19996,19997],{},"房仲業務裡就有很多容易混淆的詞：",[132,19999,20000,20012],{},[135,20001,20002],{},[138,20003,20004,20007,20009],{},[141,20005,20006],{},"業務用詞",[141,20008,6306],{},[141,20010,20011],{},"常見的混用",[152,20013,20014,20025,20035,20045],{},[138,20015,20016,20019,20022],{},[157,20017,20018],{},"委託",[157,20020,20021],{},"屋主授權經紀人銷售，有一般委託、專任委託",[157,20023,20024],{},"和「刊登」混用，但委託是合約，刊登是上架到網站",[138,20026,20027,20029,20032],{},[157,20028,18289],{},[157,20030,20031],{},"經紀人帶買方實際看屋",[157,20033,20034],{},"「看屋」、「約看」、「預約」各講各的",[138,20036,20037,20039,20042],{},[157,20038,18293],{},[157,20040,20041],{},"買方付斡旋金、提出價格，請經紀人向屋主議價",[157,20043,20044],{},"和「出價」混用，但出價不一定有斡旋金、沒有法律效力",[138,20046,20047,20050,20053],{},[157,20048,20049],{},"成交",[157,20051,20052],{},"屋主接受價格，雙方簽約",[157,20054,20055],{},"斡旋被接受就算成交？還是簽約才算？各部門定義不同",[11,20057,20058,20059,20062,20063,20065,20066,20069,20070,20073,20074,20077,20078,20080],{},"通用語言不是一份名詞表而已，它要",[23,20060,20061],{},"直接出現在程式碼裡","：類別叫 ",[15,20064,8758],{},"（斡旋）而不是 ",[15,20067,20068],{},"HouseRequest","，方法叫 ",[15,20071,20072],{},"offer.Accept()"," 而不是 ",[15,20075,20076],{},"UpdateStatus(3)","。當業務說「斡旋過期不能接受」，開發人員應該能在 ",[15,20079,8742],{}," 裡找到這條規則。",[11,20082,20083],{},"最後一列「成交」的定義不一致，就是通用語言要解決的典型問題：只要發現同一個詞有兩種意思，就要停下來釐清，而不是各自假設。",[100,20085,16362],{"id":20086},"子域subdomain",[11,20088,20089,20092],{},[23,20090,20091],{},"領域","是整個業務，例如「房仲業務」。領域很大，可以再分成幾個子域，並依重要性分類：",[132,20094,20095,20107],{},[135,20096,20097],{},[138,20098,20099,20101,20103,20105],{},[141,20100,16504],{},[141,20102,286],{},[141,20104,16341],{},[141,20106,16510],{},[152,20108,20109,20122,20135],{},[138,20110,20111,20113,20116,20119],{},[157,20112,16793],{},[157,20114,20115],{},"讓公司和競爭對手不同的部分",[157,20117,20118],{},"物件媒合、斡旋成交流程",[157,20120,20121],{},"自己做、放最好的人、持續投資",[138,20123,20124,20126,20129,20132],{},[157,20125,16796],{},[157,20127,20128],{},"業務需要，但不是競爭優勢",[157,20130,20131],{},"帶看排程、經紀人業績報表",[157,20133,20134],{},"自己做但簡單做，或外包",[138,20136,20137,20139,20142,20145],{},[157,20138,16799],{},[157,20140,20141],{},"大家都一樣的問題",[157,20143,20144],{},"會員登入、簡訊通知、金流",[157,20146,20147],{},"買現成的、用開源或雲端服務",[11,20149,16778,20150,20153],{},[23,20151,20152],{},"決定資源要放在哪裡","：核心子域值得用完整的 DDD 戰術設計慢慢打磨，通用子域則應該盡量不要自己寫。子域要怎麼找、怎麼判斷屬於哪一類，之後會另外整理一篇。",[100,20155,16375],{"id":20156},"限界上下文bounded-context",[11,20158,20159,20160,114],{},"子域是「業務長什麼樣子」（問題空間），限界上下文則是「系統怎麼切」（解法空間）：",[23,20161,20162],{},"一個模型和它的通用語言有效的範圍",[11,20164,20165],{},"最好的例子就是「物件」這個詞：",[66,20167,20168,20174,20180],{},[69,20169,20170,20173],{},[23,20171,20172],{},"刊登上下文","：物件是一筆刊登內容，有標題、照片、描述、刊登期限",[69,20175,20176,20179],{},[23,20177,20178],{},"搜尋上下文","：物件是一筆搜尋結果，有座標、價格區間、標籤，重點是查得快",[69,20181,20182,20185],{},[23,20183,20184],{},"成交上下文","：物件是一筆交易標的，有權狀、地號、所有權人、斡旋紀錄",[11,20187,20188,20189,20191],{},"硬把三者塞成一個 ",[15,20190,12489],{}," 類別，就是前面說的「一個模型用到底」。切成三個上下文後，每個上下文裡的「物件」只需要自己關心的欄位和規則，彼此透過明確的介面或事件溝通。",[11,20193,20194,20195,20198],{},"限界上下文通常也是",[23,20196,20197],{},"團隊與部署的邊界","：一個上下文由一個團隊負責，可以是一個獨立的服務，也可以是單體應用程式裡的一個模組。DDD 不要求一定要做成微服務。",[100,20200,20202],{"id":20201},"上下文映射context-map","上下文映射（Context Map）",[11,20204,20205],{},"上下文之間一定會有往來，Context Map 描述它們之間的關係。常見的幾種：",[132,20207,20208,20219],{},[135,20209,20210],{},[138,20211,20212,20215,20217],{},[141,20213,20214],{},"模式",[141,20216,286],{},[141,20218,10617],{},[152,20220,20221,20232,20243,20254,20265,20276],{},[138,20222,20223,20226,20229],{},[157,20224,20225],{},"客戶／供應商（Customer–Supplier）",[157,20227,20228],{},"下游提需求，上游排進開發計畫",[157,20230,20231],{},"搜尋上下文需要刊登上下文提供物件異動事件",[138,20233,20234,20237,20240],{},[157,20235,20236],{},"遵奉者（Conformist）",[157,20238,20239],{},"下游直接採用上游的模型，沒有議價空間",[157,20241,20242],{},"直接使用政府實價登錄資料的欄位格式",[138,20244,20245,20248,20251],{},[157,20246,20247],{},"防腐層（Anticorruption Layer）",[157,20249,20250],{},"下游加一層轉換，避免上游的模型汙染自己",[157,20252,20253],{},"串接舊的 ERP 系統時，把它的欄位轉成成交上下文自己的模型",[138,20255,20256,20259,20262],{},[157,20257,20258],{},"開放主機服務／公開語言（Open Host Service \u002F Published Language）",[157,20260,20261],{},"上游提供一套穩定、有文件的公開介面給多個下游",[157,20263,20264],{},"刊登上下文發布統一格式的「物件已上架」事件",[138,20266,20267,20270,20273],{},[157,20268,20269],{},"共享核心（Shared Kernel）",[157,20271,20272],{},"兩個上下文共用一小部分模型，修改要雙方同意",[157,20274,20275],{},"共用地址、行政區的值物件",[138,20277,20278,20281,20284],{},[157,20279,20280],{},"各行其道（Separate Ways）",[157,20282,20283],{},"整合成本太高，乾脆不整合",[157,20285,20286],{},"內部公告系統和成交系統完全獨立",[11,20288,20289,20290,20293],{},"畫 Context Map 最大的價值不是畫出漂亮的圖，而是",[23,20291,20292],{},"把團隊之間的依賴和權力關係說清楚","：如果下游是「遵奉者」，上游一改格式下游就壞，那就應該考慮加一層防腐層。",[28,20295,19948],{"id":19948},[11,20297,20298,20299,20302],{},"戰術設計是在一個限界上下文",[23,20300,20301],{},"裡面","，用來表達業務模型的一組建構元素：",[132,20304,20305,20315],{},[135,20306,20307],{},[138,20308,20309,20311,20313],{},[141,20310,11259],{},[141,20312,286],{},[141,20314,10617],{},[152,20316,20317,20330,20347,20360,20373,20386,20397],{},[138,20318,20319,20322,20325],{},[157,20320,20321],{},"實體（Entity）",[157,20323,20324],{},"有唯一識別，狀態會隨時間改變",[157,20326,20327,20329],{},[15,20328,8758],{},"：同一筆斡旋，價格或狀態變了還是同一筆",[138,20331,20332,20335,20338],{},[157,20333,20334],{},"值物件（Value Object）",[157,20336,20337],{},"沒有識別，只看值；不可變",[157,20339,20340,2276,20343,20346],{},[15,20341,20342],{},"Money",[15,20344,20345],{},"Address","：兩個 1,200 萬是一樣的",[138,20348,20349,20352,20355],{},[157,20350,20351],{},"聚合（Aggregate）",[157,20353,20354],{},"一組必須保持一致的物件，對外只透過聚合根操作",[157,20356,20357,20359],{},[15,20358,8758],{}," 聚合包含價格、斡旋金、期限",[138,20361,20362,20364,20367],{},[157,20363,11271],{},[157,20365,20366],{},"業務上發生過、其他人在意的事，用過去式命名",[157,20368,20369,20372],{},[15,20370,20371],{},"OfferAccepted","（斡旋已接受）",[138,20374,20375,20378,20381],{},[157,20376,20377],{},"Repository",[157,20379,20380],{},"以聚合為單位存取，隱藏資料庫細節",[157,20382,20383],{},[15,20384,20385],{},"IOfferRepository",[138,20387,20388,20391,20394],{},[157,20389,20390],{},"領域服務（Domain Service）",[157,20392,20393],{},"不屬於任何一個實體的業務規則",[157,20395,20396],{},"佣金計算，需要成交價、委託類型、經紀人分配比例",[138,20398,20399,20402,20405],{},[157,20400,20401],{},"應用服務（Application Service）",[157,20403,20404],{},"協調流程：讀取聚合、呼叫方法、儲存，不放業務規則",[157,20406,20407],{},[15,20408,20409],{},"AcceptOfferHandler",[100,20411,20413],{"id":20412},"聚合最重要也最容易做錯","聚合：最重要也最容易做錯",[11,20415,20416,20417,20420],{},"聚合是一個",[23,20418,20419],{},"一致性邊界","：邊界內的規則（不變條件，Invariant）在每次交易結束時都必須成立。Vaughn Vernon 在《Implementing Domain-Driven Design》整理了四個設計原則：",[211,20422,20423,20429,20435,20450],{},[69,20424,20425,20428],{},[23,20426,20427],{},"在邊界內保護真正的不變條件","：只有「必須同時成立」的規則才放在同一個聚合",[69,20430,20431,20434],{},[23,20432,20433],{},"設計小的聚合","：大聚合讀取慢、容易衝突，很多規則其實不需要即時一致",[69,20436,20437,55,20440,20442,20443,20446,20447,20449],{},[23,20438,20439],{},"用 Id 參照其他聚合",[15,20441,8758],{}," 存的是 ",[15,20444,20445],{},"ListingId","，不是整個 ",[15,20448,8762],{}," 物件",[69,20451,20452,20455],{},[23,20453,20454],{},"邊界外用最終一致","：一個交易只修改一個聚合，其他聚合透過領域事件在之後更新",[11,20457,20458,20459,20461],{},"用斡旋來看：「斡旋過期不能接受」、「已經接受的斡旋不能再撤回」是 ",[15,20460,8758],{}," 自己的規則，放在聚合裡；「斡旋被接受後，物件要標記為已斡旋、其他斡旋要通知失效」則牽涉其他聚合，透過事件處理。",[36,20463,20465],{"className":3145,"code":20464,"language":3147,"meta":44,"style":44},"public sealed record Money\n{\n    public decimal Amount { get; }\n\n    public Money(decimal amount)\n    {\n        if (amount \u003C= 0) throw new DomainException(\"金額必須大於 0\");\n        Amount = amount;\n    }\n}\n\npublic sealed class Offer : AggregateRoot\n{\n    public OfferId Id { get; }\n    public ListingId ListingId { get; }      \u002F\u002F 只用 Id 參照其他聚合\n    public BuyerId BuyerId { get; }\n    public Money Price { get; }\n    public Money EarnestMoney { get; }       \u002F\u002F 斡旋金\n    public DateTimeOffset ExpiresAt { get; }\n    public OfferStatus Status { get; private set; }\n\n    private Offer(OfferId id, ListingId listingId, BuyerId buyerId,\n        Money price, Money earnestMoney, DateTimeOffset expiresAt)\n    {\n        Id = id;\n        ListingId = listingId;\n        BuyerId = buyerId;\n        Price = price;\n        EarnestMoney = earnestMoney;\n        ExpiresAt = expiresAt;\n        Status = OfferStatus.Submitted;\n    }\n\n    public static Offer Submit(ListingId listingId, BuyerId buyerId,\n        Money price, Money earnestMoney, DateTimeOffset expiresAt, DateTimeOffset now)\n    {\n        if (expiresAt \u003C= now) throw new DomainException(\"斡旋期限必須晚於現在\");\n\n        var offer = new Offer(OfferId.New(), listingId, buyerId, price, earnestMoney, expiresAt);\n        offer.AddEvent(new OfferSubmitted(offer.Id, listingId, price.Amount));\n        return offer;\n    }\n\n    public void Accept(DateTimeOffset now)\n    {\n        if (Status != OfferStatus.Submitted)\n            throw new DomainException($\"斡旋狀態為 {Status}，不能接受\");\n        if (now > ExpiresAt)\n            throw new DomainException(\"斡旋已過期，不能接受\");\n\n        Status = OfferStatus.Accepted;\n        AddEvent(new OfferAccepted(Id, ListingId, BuyerId, Price.Amount));\n    }\n\n    public void Withdraw(DateTimeOffset now)\n    {\n        if (Status != OfferStatus.Submitted)\n            throw new DomainException($\"斡旋狀態為 {Status}，不能撤回\");\n\n        Status = OfferStatus.Withdrawn;\n        AddEvent(new OfferWithdrawn(Id, ListingId));\n    }\n}\n",[15,20466,20467,20480,20484,20502,20506,20523,20527,20555,20565,20569,20573,20577,20593,20597,20613,20632,20647,20662,20681,20697,20720,20724,20755,20780,20784,20794,20804,20814,20824,20834,20844,20854,20858,20862,20887,20914,20918,20943,20947,20969,20987,20995,21000,21005,21024,21029,21043,21069,21082,21098,21103,21113,21129,21134,21139,21157,21162,21173,21197,21202,21212,21227,21232],{"__ignoreMap":44},[376,20468,20469,20471,20474,20477],{"class":378,"line":379},[376,20470,3274],{"class":3196},[376,20472,20473],{"class":3196}," sealed",[376,20475,20476],{"class":3196}," record",[376,20478,20479],{"class":1020}," Money\n",[376,20481,20482],{"class":378,"line":386},[376,20483,3324],{"class":393},[376,20485,20486,20488,20491,20494,20496,20499],{"class":378,"line":397},[376,20487,12192],{"class":3196},[376,20489,20490],{"class":3196}," decimal",[376,20492,20493],{"class":1020}," Amount",[376,20495,9621],{"class":393},[376,20497,20498],{"class":3196},"get",[376,20500,20501],{"class":393},"; }\n",[376,20503,20504],{"class":378,"line":405},[376,20505,1036],{"emptyLinePlaceholder":1035},[376,20507,20508,20510,20513,20515,20518,20521],{"class":378,"line":418},[376,20509,12192],{"class":3196},[376,20511,20512],{"class":1020}," Money",[376,20514,3220],{"class":393},[376,20516,20517],{"class":3196},"decimal",[376,20519,20520],{"class":1020}," amount",[376,20522,3319],{"class":393},[376,20524,20525],{"class":378,"line":429},[376,20526,5704],{"class":393},[376,20528,20529,20531,20534,20536,20538,20540,20543,20545,20548,20550,20553],{"class":378,"line":437},[376,20530,13337],{"class":3196},[376,20532,20533],{"class":393}," (amount ",[376,20535,13150],{"class":3196},[376,20537,13153],{"class":560},[376,20539,5662],{"class":393},[376,20541,20542],{"class":3196},"throw",[376,20544,4692],{"class":3196},[376,20546,20547],{"class":1020}," DomainException",[376,20549,3220],{"class":393},[376,20551,20552],{"class":414},"\"金額必須大於 0\"",[376,20554,3376],{"class":393},[376,20556,20557,20560,20562],{"class":378,"line":447},[376,20558,20559],{"class":393},"        Amount ",[376,20561,3240],{"class":3196},[376,20563,20564],{"class":393}," amount;\n",[376,20566,20567],{"class":378,"line":531},[376,20568,5766],{"class":393},[376,20570,20571],{"class":378,"line":542},[376,20572,3381],{"class":393},[376,20574,20575],{"class":378,"line":552},[376,20576,1036],{"emptyLinePlaceholder":1035},[376,20578,20579,20581,20583,20585,20588,20590],{"class":378,"line":564},[376,20580,3274],{"class":3196},[376,20582,20473],{"class":3196},[376,20584,12180],{"class":3196},[376,20586,20587],{"class":1020}," Offer",[376,20589,18523],{"class":393},[376,20591,20592],{"class":1020},"AggregateRoot\n",[376,20594,20595],{"class":378,"line":575},[376,20596,3324],{"class":393},[376,20598,20599,20601,20604,20607,20609,20611],{"class":378,"line":583},[376,20600,12192],{"class":3196},[376,20602,20603],{"class":1020}," OfferId",[376,20605,20606],{"class":1020}," Id",[376,20608,9621],{"class":393},[376,20610,20498],{"class":3196},[376,20612,20501],{"class":393},[376,20614,20615,20617,20620,20622,20624,20626,20629],{"class":378,"line":597},[376,20616,12192],{"class":3196},[376,20618,20619],{"class":1020}," ListingId",[376,20621,20619],{"class":1020},[376,20623,9621],{"class":393},[376,20625,20498],{"class":3196},[376,20627,20628],{"class":393},"; }      ",[376,20630,20631],{"class":382},"\u002F\u002F 只用 Id 參照其他聚合\n",[376,20633,20634,20636,20639,20641,20643,20645],{"class":378,"line":605},[376,20635,12192],{"class":3196},[376,20637,20638],{"class":1020}," BuyerId",[376,20640,20638],{"class":1020},[376,20642,9621],{"class":393},[376,20644,20498],{"class":3196},[376,20646,20501],{"class":393},[376,20648,20649,20651,20653,20656,20658,20660],{"class":378,"line":616},[376,20650,12192],{"class":3196},[376,20652,20512],{"class":1020},[376,20654,20655],{"class":1020}," Price",[376,20657,9621],{"class":393},[376,20659,20498],{"class":3196},[376,20661,20501],{"class":393},[376,20663,20664,20666,20668,20671,20673,20675,20678],{"class":378,"line":624},[376,20665,12192],{"class":3196},[376,20667,20512],{"class":1020},[376,20669,20670],{"class":1020}," EarnestMoney",[376,20672,9621],{"class":393},[376,20674,20498],{"class":3196},[376,20676,20677],{"class":393},"; }       ",[376,20679,20680],{"class":382},"\u002F\u002F 斡旋金\n",[376,20682,20683,20685,20688,20691,20693,20695],{"class":378,"line":635},[376,20684,12192],{"class":3196},[376,20686,20687],{"class":1020}," DateTimeOffset",[376,20689,20690],{"class":1020}," ExpiresAt",[376,20692,9621],{"class":393},[376,20694,20498],{"class":3196},[376,20696,20501],{"class":393},[376,20698,20699,20701,20704,20707,20709,20711,20714,20716,20718],{"class":378,"line":5815},[376,20700,12192],{"class":3196},[376,20702,20703],{"class":1020}," OfferStatus",[376,20705,20706],{"class":1020}," Status",[376,20708,9621],{"class":393},[376,20710,20498],{"class":3196},[376,20712,20713],{"class":393},"; ",[376,20715,13062],{"class":3196},[376,20717,15253],{"class":3196},[376,20719,20501],{"class":393},[376,20721,20722],{"class":378,"line":7233},[376,20723,1036],{"emptyLinePlaceholder":1035},[376,20725,20726,20729,20731,20733,20736,20738,20740,20742,20745,20747,20750,20753],{"class":378,"line":7243},[376,20727,20728],{"class":3196},"    private",[376,20730,20587],{"class":1020},[376,20732,3220],{"class":393},[376,20734,20735],{"class":1020},"OfferId",[376,20737,12502],{"class":1020},[376,20739,3188],{"class":393},[376,20741,20445],{"class":1020},[376,20743,20744],{"class":1020}," listingId",[376,20746,3188],{"class":393},[376,20748,20749],{"class":1020},"BuyerId",[376,20751,20752],{"class":1020}," buyerId",[376,20754,3226],{"class":393},[376,20756,20757,20760,20763,20765,20767,20770,20772,20775,20778],{"class":378,"line":7251},[376,20758,20759],{"class":1020},"        Money",[376,20761,20762],{"class":1020}," price",[376,20764,3188],{"class":393},[376,20766,20342],{"class":1020},[376,20768,20769],{"class":1020}," earnestMoney",[376,20771,3188],{"class":393},[376,20773,20774],{"class":1020},"DateTimeOffset",[376,20776,20777],{"class":1020}," expiresAt",[376,20779,3319],{"class":393},[376,20781,20782],{"class":378,"line":7721},[376,20783,5704],{"class":393},[376,20785,20786,20789,20791],{"class":378,"line":7729},[376,20787,20788],{"class":393},"        Id ",[376,20790,3240],{"class":3196},[376,20792,20793],{"class":393}," id;\n",[376,20795,20796,20799,20801],{"class":378,"line":7740},[376,20797,20798],{"class":393},"        ListingId ",[376,20800,3240],{"class":3196},[376,20802,20803],{"class":393}," listingId;\n",[376,20805,20806,20809,20811],{"class":378,"line":9569},[376,20807,20808],{"class":393},"        BuyerId ",[376,20810,3240],{"class":3196},[376,20812,20813],{"class":393}," buyerId;\n",[376,20815,20816,20819,20821],{"class":378,"line":9581},[376,20817,20818],{"class":393},"        Price ",[376,20820,3240],{"class":3196},[376,20822,20823],{"class":393}," price;\n",[376,20825,20826,20829,20831],{"class":378,"line":9590},[376,20827,20828],{"class":393},"        EarnestMoney ",[376,20830,3240],{"class":3196},[376,20832,20833],{"class":393}," earnestMoney;\n",[376,20835,20836,20839,20841],{"class":378,"line":9595},[376,20837,20838],{"class":393},"        ExpiresAt ",[376,20840,3240],{"class":3196},[376,20842,20843],{"class":393}," expiresAt;\n",[376,20845,20846,20849,20851],{"class":378,"line":9600},[376,20847,20848],{"class":393},"        Status ",[376,20850,3240],{"class":3196},[376,20852,20853],{"class":393}," OfferStatus.Submitted;\n",[376,20855,20856],{"class":378,"line":9605},[376,20857,5766],{"class":393},[376,20859,20860],{"class":378,"line":9615},[376,20861,1036],{"emptyLinePlaceholder":1035},[376,20863,20864,20866,20868,20870,20873,20875,20877,20879,20881,20883,20885],{"class":378,"line":9639},[376,20865,12192],{"class":3196},[376,20867,3411],{"class":3196},[376,20869,20587],{"class":1020},[376,20871,20872],{"class":1020}," Submit",[376,20874,3220],{"class":393},[376,20876,20445],{"class":1020},[376,20878,20744],{"class":1020},[376,20880,3188],{"class":393},[376,20882,20749],{"class":1020},[376,20884,20752],{"class":1020},[376,20886,3226],{"class":393},[376,20888,20889,20891,20893,20895,20897,20899,20901,20903,20905,20907,20909,20912],{"class":378,"line":9651},[376,20890,20759],{"class":1020},[376,20892,20762],{"class":1020},[376,20894,3188],{"class":393},[376,20896,20342],{"class":1020},[376,20898,20769],{"class":1020},[376,20900,3188],{"class":393},[376,20902,20774],{"class":1020},[376,20904,20777],{"class":1020},[376,20906,3188],{"class":393},[376,20908,20774],{"class":1020},[376,20910,20911],{"class":1020}," now",[376,20913,3319],{"class":393},[376,20915,20916],{"class":378,"line":9656},[376,20917,5704],{"class":393},[376,20919,20920,20922,20925,20927,20930,20932,20934,20936,20938,20941],{"class":378,"line":13527},[376,20921,13337],{"class":3196},[376,20923,20924],{"class":393}," (expiresAt ",[376,20926,13150],{"class":3196},[376,20928,20929],{"class":393}," now) ",[376,20931,20542],{"class":3196},[376,20933,4692],{"class":3196},[376,20935,20547],{"class":1020},[376,20937,3220],{"class":393},[376,20939,20940],{"class":414},"\"斡旋期限必須晚於現在\"",[376,20942,3376],{"class":393},[376,20944,20945],{"class":378,"line":13532},[376,20946,1036],{"emptyLinePlaceholder":1035},[376,20948,20949,20951,20954,20956,20958,20960,20963,20966],{"class":378,"line":13543},[376,20950,13379],{"class":3196},[376,20952,20953],{"class":1020}," offer",[376,20955,3313],{"class":3196},[376,20957,4692],{"class":3196},[376,20959,20587],{"class":1020},[376,20961,20962],{"class":393},"(OfferId.",[376,20964,20965],{"class":1020},"New",[376,20967,20968],{"class":393},"(), listingId, buyerId, price, earnestMoney, expiresAt);\n",[376,20970,20971,20974,20977,20979,20981,20984],{"class":378,"line":13548},[376,20972,20973],{"class":393},"        offer.",[376,20975,20976],{"class":1020},"AddEvent",[376,20978,3220],{"class":393},[376,20980,3197],{"class":3196},[376,20982,20983],{"class":1020}," OfferSubmitted",[376,20985,20986],{"class":393},"(offer.Id, listingId, price.Amount));\n",[376,20988,20990,20992],{"class":378,"line":20989},41,[376,20991,3485],{"class":3196},[376,20993,20994],{"class":393}," offer;\n",[376,20996,20998],{"class":378,"line":20997},42,[376,20999,5766],{"class":393},[376,21001,21003],{"class":378,"line":21002},43,[376,21004,1036],{"emptyLinePlaceholder":1035},[376,21006,21008,21010,21013,21016,21018,21020,21022],{"class":378,"line":21007},44,[376,21009,12192],{"class":3196},[376,21011,21012],{"class":3196}," void",[376,21014,21015],{"class":1020}," Accept",[376,21017,3220],{"class":393},[376,21019,20774],{"class":1020},[376,21021,20911],{"class":1020},[376,21023,3319],{"class":393},[376,21025,21027],{"class":378,"line":21026},45,[376,21028,5704],{"class":393},[376,21030,21032,21034,21037,21040],{"class":378,"line":21031},46,[376,21033,13337],{"class":3196},[376,21035,21036],{"class":393}," (Status ",[376,21038,21039],{"class":3196},"!=",[376,21041,21042],{"class":393}," OfferStatus.Submitted)\n",[376,21044,21046,21049,21051,21053,21055,21058,21060,21062,21064,21067],{"class":378,"line":21045},47,[376,21047,21048],{"class":3196},"            throw",[376,21050,4692],{"class":3196},[376,21052,20547],{"class":1020},[376,21054,3220],{"class":393},[376,21056,21057],{"class":414},"$\"斡旋狀態為 ",[376,21059,4729],{"class":414},[376,21061,10020],{"class":393},[376,21063,4741],{"class":414},[376,21065,21066],{"class":414},"，不能接受\"",[376,21068,3376],{"class":393},[376,21070,21072,21074,21077,21079],{"class":378,"line":21071},48,[376,21073,13337],{"class":3196},[376,21075,21076],{"class":393}," (now ",[376,21078,15391],{"class":3196},[376,21080,21081],{"class":393}," ExpiresAt)\n",[376,21083,21085,21087,21089,21091,21093,21096],{"class":378,"line":21084},49,[376,21086,21048],{"class":3196},[376,21088,4692],{"class":3196},[376,21090,20547],{"class":1020},[376,21092,3220],{"class":393},[376,21094,21095],{"class":414},"\"斡旋已過期，不能接受\"",[376,21097,3376],{"class":393},[376,21099,21101],{"class":378,"line":21100},50,[376,21102,1036],{"emptyLinePlaceholder":1035},[376,21104,21106,21108,21110],{"class":378,"line":21105},51,[376,21107,20848],{"class":393},[376,21109,3240],{"class":3196},[376,21111,21112],{"class":393}," OfferStatus.Accepted;\n",[376,21114,21116,21119,21121,21123,21126],{"class":378,"line":21115},52,[376,21117,21118],{"class":1020},"        AddEvent",[376,21120,3220],{"class":393},[376,21122,3197],{"class":3196},[376,21124,21125],{"class":1020}," OfferAccepted",[376,21127,21128],{"class":393},"(Id, ListingId, BuyerId, Price.Amount));\n",[376,21130,21132],{"class":378,"line":21131},53,[376,21133,5766],{"class":393},[376,21135,21137],{"class":378,"line":21136},54,[376,21138,1036],{"emptyLinePlaceholder":1035},[376,21140,21142,21144,21146,21149,21151,21153,21155],{"class":378,"line":21141},55,[376,21143,12192],{"class":3196},[376,21145,21012],{"class":3196},[376,21147,21148],{"class":1020}," Withdraw",[376,21150,3220],{"class":393},[376,21152,20774],{"class":1020},[376,21154,20911],{"class":1020},[376,21156,3319],{"class":393},[376,21158,21160],{"class":378,"line":21159},56,[376,21161,5704],{"class":393},[376,21163,21165,21167,21169,21171],{"class":378,"line":21164},57,[376,21166,13337],{"class":3196},[376,21168,21036],{"class":393},[376,21170,21039],{"class":3196},[376,21172,21042],{"class":393},[376,21174,21176,21178,21180,21182,21184,21186,21188,21190,21192,21195],{"class":378,"line":21175},58,[376,21177,21048],{"class":3196},[376,21179,4692],{"class":3196},[376,21181,20547],{"class":1020},[376,21183,3220],{"class":393},[376,21185,21057],{"class":414},[376,21187,4729],{"class":414},[376,21189,10020],{"class":393},[376,21191,4741],{"class":414},[376,21193,21194],{"class":414},"，不能撤回\"",[376,21196,3376],{"class":393},[376,21198,21200],{"class":378,"line":21199},59,[376,21201,1036],{"emptyLinePlaceholder":1035},[376,21203,21205,21207,21209],{"class":378,"line":21204},60,[376,21206,20848],{"class":393},[376,21208,3240],{"class":3196},[376,21210,21211],{"class":393}," OfferStatus.Withdrawn;\n",[376,21213,21215,21217,21219,21221,21224],{"class":378,"line":21214},61,[376,21216,21118],{"class":1020},[376,21218,3220],{"class":393},[376,21220,3197],{"class":3196},[376,21222,21223],{"class":1020}," OfferWithdrawn",[376,21225,21226],{"class":393},"(Id, ListingId));\n",[376,21228,21230],{"class":378,"line":21229},62,[376,21231,5766],{"class":393},[376,21233,21235],{"class":378,"line":21234},63,[376,21236,3381],{"class":393},[11,21238,21239],{},"幾個重點：",[66,21241,21242,21254,21263],{},[69,21243,21244,21247,21248,2276,21250,21253],{},[23,21245,21246],{},"沒有公開的 setter","：狀態只能透過 ",[15,21249,8782],{},[15,21251,21252],{},"Withdraw()"," 這些有業務意義的方法改變，規則不會被繞過",[69,21255,21256,55,21259,21262],{},[23,21257,21258],{},"時間從外面傳入",[15,21260,21261],{},"now"," 由呼叫端提供，單元測試時可以指定任意時間",[69,21264,21265,55,21268,21270],{},[23,21266,21267],{},"事件先記在聚合上",[15,21269,20976],{}," 只是暫存，儲存時才和聚合一起寫入（例如寫進同一個資料庫的 Outbox 表），避免「資料存了、事件沒送」",[11,21272,21273],{},"應用服務只負責協調：",[36,21275,21277],{"className":3145,"code":21276,"language":3147,"meta":44,"style":44},"public sealed class AcceptOfferHandler\n{\n    private readonly IOfferRepository _offers;\n    private readonly TimeProvider _clock;\n\n    public AcceptOfferHandler(IOfferRepository offers, TimeProvider clock)\n    {\n        _offers = offers;\n        _clock = clock;\n    }\n\n    public async Task Handle(AcceptOfferCommand command, CancellationToken ct)\n    {\n        var offer = await _offers.GetAsync(command.OfferId, ct)\n            ?? throw new NotFoundException($\"找不到斡旋 {command.OfferId}\");\n\n        offer.Accept(_clock.GetUtcNow());\n\n        await _offers.SaveAsync(offer, ct);   \u002F\u002F 同一個交易：更新聚合 + 寫入 Outbox\n    }\n}\n",[15,21278,21279,21290,21294,21308,21322,21326,21350,21354,21364,21374,21378,21382,21409,21413,21432,21465,21469,21484,21488,21503,21507],{"__ignoreMap":44},[376,21280,21281,21283,21285,21287],{"class":378,"line":379},[376,21282,3274],{"class":3196},[376,21284,20473],{"class":3196},[376,21286,12180],{"class":3196},[376,21288,21289],{"class":1020}," AcceptOfferHandler\n",[376,21291,21292],{"class":378,"line":386},[376,21293,3324],{"class":393},[376,21295,21296,21298,21300,21303,21306],{"class":378,"line":397},[376,21297,20728],{"class":3196},[376,21299,12197],{"class":3196},[376,21301,21302],{"class":1020}," IOfferRepository",[376,21304,21305],{"class":1020}," _offers",[376,21307,3428],{"class":393},[376,21309,21310,21312,21314,21317,21320],{"class":378,"line":405},[376,21311,20728],{"class":3196},[376,21313,12197],{"class":3196},[376,21315,21316],{"class":1020}," TimeProvider",[376,21318,21319],{"class":1020}," _clock",[376,21321,3428],{"class":393},[376,21323,21324],{"class":378,"line":418},[376,21325,1036],{"emptyLinePlaceholder":1035},[376,21327,21328,21330,21333,21335,21337,21340,21342,21345,21348],{"class":378,"line":429},[376,21329,12192],{"class":3196},[376,21331,21332],{"class":1020}," AcceptOfferHandler",[376,21334,3220],{"class":393},[376,21336,20385],{"class":1020},[376,21338,21339],{"class":1020}," offers",[376,21341,3188],{"class":393},[376,21343,21344],{"class":1020},"TimeProvider",[376,21346,21347],{"class":1020}," clock",[376,21349,3319],{"class":393},[376,21351,21352],{"class":378,"line":437},[376,21353,5704],{"class":393},[376,21355,21356,21359,21361],{"class":378,"line":447},[376,21357,21358],{"class":393},"        _offers ",[376,21360,3240],{"class":3196},[376,21362,21363],{"class":393}," offers;\n",[376,21365,21366,21369,21371],{"class":378,"line":531},[376,21367,21368],{"class":393},"        _clock ",[376,21370,3240],{"class":3196},[376,21372,21373],{"class":393}," clock;\n",[376,21375,21376],{"class":378,"line":542},[376,21377,5766],{"class":393},[376,21379,21380],{"class":378,"line":552},[376,21381,1036],{"emptyLinePlaceholder":1035},[376,21383,21384,21386,21388,21390,21393,21395,21398,21401,21403,21405,21407],{"class":378,"line":564},[376,21385,12192],{"class":3196},[376,21387,3277],{"class":3196},[376,21389,3280],{"class":1020},[376,21391,21392],{"class":1020}," Handle",[376,21394,3220],{"class":393},[376,21396,21397],{"class":1020},"AcceptOfferCommand",[376,21399,21400],{"class":1020}," command",[376,21402,3188],{"class":393},[376,21404,3307],{"class":1020},[376,21406,19061],{"class":1020},[376,21408,3319],{"class":393},[376,21410,21411],{"class":378,"line":575},[376,21412,5704],{"class":393},[376,21414,21415,21417,21419,21421,21423,21426,21429],{"class":378,"line":583},[376,21416,13379],{"class":3196},[376,21418,20953],{"class":1020},[376,21420,3313],{"class":3196},[376,21422,9704],{"class":3196},[376,21424,21425],{"class":393}," _offers.",[376,21427,21428],{"class":1020},"GetAsync",[376,21430,21431],{"class":393},"(command.OfferId, ct)\n",[376,21433,21434,21437,21440,21442,21445,21447,21450,21452,21455,21457,21459,21461,21463],{"class":378,"line":597},[376,21435,21436],{"class":3196},"            ??",[376,21438,21439],{"class":3196}," throw",[376,21441,4692],{"class":3196},[376,21443,21444],{"class":1020}," NotFoundException",[376,21446,3220],{"class":393},[376,21448,21449],{"class":414},"$\"找不到斡旋 ",[376,21451,4729],{"class":414},[376,21453,21454],{"class":393},"command",[376,21456,4735],{"class":414},[376,21458,20735],{"class":393},[376,21460,4741],{"class":414},[376,21462,4744],{"class":414},[376,21464,3376],{"class":393},[376,21466,21467],{"class":378,"line":605},[376,21468,1036],{"emptyLinePlaceholder":1035},[376,21470,21471,21473,21476,21479,21482],{"class":378,"line":616},[376,21472,20973],{"class":393},[376,21474,21475],{"class":1020},"Accept",[376,21477,21478],{"class":393},"(_clock.",[376,21480,21481],{"class":1020},"GetUtcNow",[376,21483,12018],{"class":393},[376,21485,21486],{"class":378,"line":624},[376,21487,1036],{"emptyLinePlaceholder":1035},[376,21489,21490,21492,21494,21497,21500],{"class":378,"line":635},[376,21491,9572],{"class":3196},[376,21493,21425],{"class":393},[376,21495,21496],{"class":1020},"SaveAsync",[376,21498,21499],{"class":393},"(offer, ct);   ",[376,21501,21502],{"class":382},"\u002F\u002F 同一個交易：更新聚合 + 寫入 Outbox\n",[376,21504,21505],{"class":378,"line":5815},[376,21506,5766],{"class":393},[376,21508,21509],{"class":378,"line":7233},[376,21510,3381],{"class":393},[11,21512,21513],{},[1952,21514],{"alt":21515,"src":21516},"一次「接受斡旋」的處理流程：屋主發出接受斡旋的命令，Offer 聚合檢查狀態為已提出且未過期，通過後狀態改為已接受並發出 OfferAccepted 事件，這三步在交易 1 內完成，只修改 Offer 一個聚合；事件送達後，政策「斡旋被接受時，物件改為已斡旋」觸發，Listing 聚合在交易 2 中把狀態改為已斡旋並發出 ListingUnderOffer 事件，這是最終一致。聚合之間不共用交易，用 Id 參照、用領域事件串接","\u002Fimages\u002Farticles\u002Fddd-aggregate-offer.svg",[11,21518,21519,21520,21522,21523,21525,21526,21529],{},"如果把 ",[15,21521,8762],{}," 和所有 ",[15,21524,8758],{}," 放進同一個大聚合，「接受斡旋」和「物件改狀態」就能在一個交易裡完成，看起來比較簡單。但代價是每次有人提出斡旋、經紀人改刊登文案，都要鎖住同一個大聚合，熱門物件很容易出現併發衝突。",[23,21527,21528],{},"先問業務：物件狀態晚幾秒更新，會不會有問題？"," 大多數時候答案是不會，那就應該拆開。",[28,21531,21533],{"id":21532},"協作探索event-storming","協作探索：Event Storming",[11,21535,21536,21537,21540],{},"前面的子域、上下文、聚合都需要一個前提：",[23,21538,21539],{},"真的理解業務","。這份理解在領域專家（業務、PO、資深經紀人）的腦袋裡，不在需求文件裡。",[11,21542,21543],{},"Event Storming 是 Alberto Brandolini 提出的工作坊形式：把領域專家和開發人員找到同一面牆前，用不同顏色的便利貼，沿著時間軸貼出業務上發生的所有事件（橘色），再補上觸發事件的命令（藍色）、角色、政策、外部系統，以及有疑問的熱點。",[11,21545,21546],{},"它有三種層次：",[132,21548,21549,21559],{},[135,21550,21551],{},[138,21552,21553,21555,21557],{},[141,21554,11102],{},[141,21556,9906],{},[141,21558,8397],{},[152,21560,21561,21571,21581],{},[138,21562,21563,21565,21568],{},[157,21564,11116],{},[157,21566,21567],{},"看清楚整個業務的全貌",[157,21569,21570],{},"完整的事件時間軸、熱點、子域與上下文的候選邊界",[138,21572,21573,21575,21578],{},[157,21574,11130],{},[157,21576,21577],{},"拆解單一流程",[157,21579,21580],{},"命令、政策、讀取模型、角色之間的完整流程",[138,21582,21583,21585,21588],{},[157,21584,11144],{},[157,21586,21587],{},"設計程式",[157,21589,21590],{},"聚合的候選、每個聚合負責的命令與不變條件",[11,21592,21593,21594,21597],{},"一場 Big Picture 下來，最有價值的常常不是便利貼本身，而是",[23,21595,21596],{},"大家發現彼此的理解不一樣","：業務部門以為「斡旋被接受」就是成交，財務部門以為「簽約完成」才是成交，這就是一個熱點，也是一個可能的上下文邊界。實際怎麼準備、怎麼引導，之後會另外整理一篇。",[28,21599,21601],{"id":21600},"和六角形架構cqrs-的關係","和六角形架構、CQRS 的關係",[11,21603,21604],{},"DDD 不是一種架構，而是一種設計方法，它和其他架構模式是互補的：",[66,21606,21607,21612,21620],{},[69,21608,21609,21611],{},[23,21610,16826],{},"決定「依賴方向」：戰術設計的聚合、值物件、領域事件放在核心，Repository 是核心定義的 Port，EF Core 實作是 Adapter",[69,21613,21614,21619],{},[23,21615,21616],{},[119,21617,21618],{"href":16687},"CQRS"," 決定「讀寫分開」：聚合只負責寫入時的規則，查詢需求（例如搜尋列表）不需要經過聚合，直接讀取讀取模型",[69,21621,21622,21625],{},[23,21623,21624],{},"領域事件","是兩者之間的橋梁：聚合發出事件，讀取模型根據事件更新",[28,21627,21629],{"id":21628},"什麼時候不需要-ddd","什麼時候不需要 DDD",[66,21631,21632,21638,21644],{},[69,21633,21634,21637],{},[23,21635,21636],{},"以 CRUD 為主的系統","：後台設定頁、簡單的資料維護，沒有什麼業務規則，用聚合和領域事件只會增加複雜度",[69,21639,21640,21643],{},[23,21641,21642],{},"找不到領域專家","：DDD 的前提是和懂業務的人持續對話。如果需求只能從文件和截圖猜，通用語言就建立不起來",[69,21645,21646,21648],{},[23,21647,16799],{},"：會員登入、檔案上傳這類問題，用現成方案就好",[11,21650,21651,21652,21655],{},"同一個系統裡，",[23,21653,21654],{},"核心子域用完整的 DDD，其他部分用簡單的做法","，這才是 DDD 建議的投資方式。",[28,21657,21658],{"id":21658},"常見誤解",[66,21660,21661,21667,21673,21679],{},[69,21662,21663,21666],{},[23,21664,21665],{},"DDD 等於微服務","：限界上下文可以是單體裡的一個模組。先在單體裡把邊界切清楚，之後要拆服務才拆得動；邊界沒切清楚就拆成微服務，只會得到分散式的大泥球",[69,21668,21669,21672],{},[23,21670,21671],{},"有 Entity、Repository 資料夾就是 DDD","：如果業務規則還是寫在 Service 裡、Entity 只有 getter\u002Fsetter（貧血模型），那只是換了資料夾名稱",[69,21674,21675,21678],{},[23,21676,21677],{},"一個聚合對應一張資料表","：聚合是一致性邊界，不是資料表。一個聚合可以存成多張表，也可以存成一份 JSON",[69,21680,21681,21684],{},[23,21682,21683],{},"一開始就要設計出正確的模型","：模型會隨著對業務的理解而演進。Evans 稱之為「突破」：某次討論後發現一個更好的概念，模型就要跟著重構",[28,21686,1890],{"id":1890},[11,21688,21689],{},"DDD 的重點不是 Entity 和 Repository，而是先和領域專家一起把業務攤開（Event Storming），建立團隊共用的語言，依照業務的重要性和語言的差異切出限界上下文（戰略設計），最後才在每個上下文裡用聚合守住真正需要即時一致的規則、用領域事件串起其他部分（戰術設計）。",[1132,21691,21692],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":44,"searchDepth":386,"depth":386,"links":21694},[21695,21696,21697,21703,21706,21707,21708,21709,21710],{"id":19865,"depth":386,"text":19866},{"id":19905,"depth":386,"text":19906},{"id":19937,"depth":386,"text":19937,"children":21698},[21699,21700,21701,21702],{"id":19987,"depth":397,"text":19988},{"id":20086,"depth":397,"text":16362},{"id":20156,"depth":397,"text":16375},{"id":20201,"depth":397,"text":20202},{"id":19948,"depth":386,"text":19948,"children":21704},[21705],{"id":20412,"depth":397,"text":20413},{"id":21532,"depth":386,"text":21533},{"id":21600,"depth":386,"text":21601},{"id":21628,"depth":386,"text":21629},{"id":21658,"depth":386,"text":21658},{"id":1890,"depth":386,"text":1890},"2024-01-16","在 六角形架構 那篇，我把業務規則都放進「核心」，外部技術變成可以抽換的 Adapter。但做完之後留下兩個問題：核心裡面的模型要怎麼設計？ 以及系統越長越大時，一個核心要不要切成好幾個？要從哪裡切？",{},{"title":19845,"description":21712},"articles\u002Fddd-overview-strategic-tactical",[8964,11785,3037,3039,10104],"VYjDNRSWnEnRljX3oFgbnHcfI-IrL76GTJe31FYU63o",{"id":21719,"title":21720,"body":21721,"date":22985,"description":44,"excerpt":1156,"extension":1157,"meta":22986,"navigation":1035,"path":16825,"seo":22987,"stem":22988,"tags":22989,"__hash__":22992},"articles\u002Farticles\u002Fhexagonal-architecture-dotnet.md","六角形架構（Ports & Adapters）在 .NET 的實作：讓圖片從本機換到 S3 不用改一行業務程式",{"type":8,"value":21722,"toc":22974},[21723,21726,21732,21751,21754,21758,21768,21774,21836,21840,21846,21864,22174,22178,22181,22626,22632,22683,22693,22696,22699,22705,22718,22728,22731,22737,22919,22928,22934,22937,22948,22952,22955,22966,22968,22971],[28,21724,21725],{"id":21725},"傳統分層架構的問題",[11,21727,21728,21729,55],{},"最常見的三層架構是 Presentation → Business Logic → Data Access，依賴一路往下。問題在於",[23,21730,21731],{},"業務邏輯依賴了資料存取層",[66,21733,21734,21742,21748],{},[69,21735,21736,21737,21739,21740],{},"業務邏輯直接 ",[15,21738,3197],{}," 或注入具體的 Repository 類別，裡面是 EF Core 的 ",[15,21741,19352],{},[69,21743,21744,21745],{},"要上傳圖片，業務邏輯裡直接呼叫 AWS SDK 的 ",[15,21746,21747],{},"PutObjectAsync",[69,21749,21750],{},"要呼叫 GPT，業務邏輯裡直接組 HTTP 請求",[11,21752,21753],{},"結果是：想測試「新增訊息時，聊天室的標題要自動以第一句話命名」這個規則，得先準備資料庫、S3、OpenAI 的金鑰。想把圖片從 S3 換成本機磁碟（開發環境不想連 AWS），得修改業務邏輯的程式碼。",[28,21755,21757],{"id":21756},"六角形架構核心在中間外部世界在外圍","六角形架構：核心在中間，外部世界在外圍",[11,21759,21760,21763,21764,21767],{},[23,21761,21762],{},"六角形架構（Hexagonal Architecture）","，又稱 ",[23,21765,21766],{},"Ports & Adapters","，由 Alistair Cockburn 提出。六角形本身沒有特別的意義，只是畫起來比圓形容易標出很多個「面」，每一面代表一種和外界溝通的方式。",[11,21769,21770],{},[1952,21771],{"alt":21772,"src":21773},"六角形架構示意圖：中心是 Domain（ChatRoom 聚合根與領域事件），外圍是 Application UseCase；左側 Driving Adapter（Web API Controller、SignalR Hub、單元測試）呼叫核心定義的 In Port；右側 Out Port（IChatRoomRepository、IImageStorage、IChatCompletion）由 Driven Adapter 實作（EF Core SQL Server、Local 或 S3 圖片儲存、OpenAI API 或測試用假物件）；所有箭頭都是依賴方向，Adapter 依賴核心定義的 Port，核心不認識任何 Adapter","\u002Fimages\u002Farticles\u002Fhexagonal-architecture.svg",[66,21775,21776,21786,21816],{},[69,21777,21778,21781,21782,21785],{},[23,21779,21780],{},"核心（Core）","：Domain（實體、聚合根、領域事件、商業規則）與 Application（UseCase，也就是應用程式提供的功能）。核心",[23,21783,21784],{},"只包含業務","，不引用任何技術框架",[69,21787,21788,21791,21792,21795,21796],{},[23,21789,21790],{},"Port（埠）","：核心定義的",[23,21793,21794],{},"介面","，描述「我需要什麼」或「我提供什麼」\n",[66,21797,21798,21807],{},[69,21799,21800,21803,21804],{},[23,21801,21802],{},"In Port（Driving Port）","：核心對外提供的功能，例如 ",[15,21805,21806],{},"IChatRoomService.CreateAsync()",[69,21808,21809,21812,21813],{},[23,21810,21811],{},"Out Port（Driven Port）","：核心需要外部提供的能力，例如「我需要一個能儲存圖片的東西」",[15,21814,21815],{},"IImageStorage",[69,21817,21818,21821,21822],{},[23,21819,21820],{},"Adapter（轉接器）","：把外部技術接到 Port 上的實作\n",[66,21823,21824,21830],{},[69,21825,21826,21829],{},[23,21827,21828],{},"Driving Adapter","：呼叫核心的一方，例如 Web API Controller、SignalR Hub、排程、單元測試",[69,21831,21832,21835],{},[23,21833,21834],{},"Driven Adapter","：被核心呼叫的一方，例如 EF Core Repository、S3 圖片儲存、OpenAI 用戶端",[28,21837,21839],{"id":21838},"最重要的規則依賴方向往內","最重要的規則：依賴方向往內",[11,21841,21842,21843,114],{},"六角形架構的核心規則只有一條：",[23,21844,21845],{},"所有依賴都指向核心，核心不依賴任何外部技術",[11,21847,21848,21849,21852,21853,21856,21857,21860,21861,21863],{},"Out Port 的介面",[23,21850,21851],{},"定義在核心裡","，實作",[23,21854,21855],{},"放在外圍","。這就是",[23,21858,21859],{},"依賴反轉原則（Dependency Inversion Principle）","：核心說「我需要一個 ",[15,21862,21815],{},"」，至於它是本機磁碟還是 S3，核心完全不知道，也不在乎。",[36,21865,21867],{"className":3145,"code":21866,"language":3147,"meta":44,"style":44},"\u002F\u002F 核心：定義 Port（介面放在 UseCase 專案中）\npublic interface IImageStorage\n{\n    Task\u003Cstring> SaveAsync(string fileName, Stream content, CancellationToken cancellationToken);\n}\n\n\u002F\u002F 核心：UseCase 只依賴 Port\npublic class AddImageMessageService\n{\n    private readonly IImageStorage _imageStorage;\n    private readonly IChatRoomRepository _repository;\n\n    public AddImageMessageService(IImageStorage imageStorage, IChatRoomRepository repository)\n    {\n        _imageStorage = imageStorage;\n        _repository = repository;\n    }\n\n    public async Task HandleAsync(Guid chatRoomId, string prompt, Stream image, CancellationToken ct)\n    {\n        var chatRoom = await _repository.GetAsync(chatRoomId, ct);\n        var url = await _imageStorage.SaveAsync($\"{Guid.NewGuid()}.png\", image, ct);\n\n        chatRoom.AddImageMessage(prompt, url);   \u002F\u002F 商業規則在聚合根裡\n        await _repository.SaveAsync(chatRoom, ct);\n    }\n}\n",[15,21868,21869,21874,21883,21887,21922,21926,21930,21935,21944,21948,21962,21976,21980,22004,22008,22018,22028,22032,22036,22076,22080,22099,22137,22141,22155,22166,22170],{"__ignoreMap":44},[376,21870,21871],{"class":378,"line":379},[376,21872,21873],{"class":382},"\u002F\u002F 核心：定義 Port（介面放在 UseCase 專案中）\n",[376,21875,21876,21878,21880],{"class":378,"line":386},[376,21877,3274],{"class":3196},[376,21879,18461],{"class":3196},[376,21881,21882],{"class":1020}," IImageStorage\n",[376,21884,21885],{"class":378,"line":397},[376,21886,3324],{"class":393},[376,21888,21889,21891,21893,21895,21897,21899,21901,21903,21906,21908,21911,21914,21916,21918,21920],{"class":378,"line":405},[376,21890,18484],{"class":1020},[376,21892,3176],{"class":393},[376,21894,5012],{"class":3196},[376,21896,3288],{"class":393},[376,21898,21496],{"class":1020},[376,21900,3220],{"class":393},[376,21902,5012],{"class":3196},[376,21904,21905],{"class":1020}," fileName",[376,21907,3188],{"class":393},[376,21909,21910],{"class":1020},"Stream",[376,21912,21913],{"class":1020}," content",[376,21915,3188],{"class":393},[376,21917,3307],{"class":1020},[376,21919,3310],{"class":1020},[376,21921,3376],{"class":393},[376,21923,21924],{"class":378,"line":418},[376,21925,3381],{"class":393},[376,21927,21928],{"class":378,"line":429},[376,21929,1036],{"emptyLinePlaceholder":1035},[376,21931,21932],{"class":378,"line":437},[376,21933,21934],{"class":382},"\u002F\u002F 核心：UseCase 只依賴 Port\n",[376,21936,21937,21939,21941],{"class":378,"line":447},[376,21938,3274],{"class":3196},[376,21940,12180],{"class":3196},[376,21942,21943],{"class":1020}," AddImageMessageService\n",[376,21945,21946],{"class":378,"line":531},[376,21947,3324],{"class":393},[376,21949,21950,21952,21954,21957,21960],{"class":378,"line":542},[376,21951,20728],{"class":3196},[376,21953,12197],{"class":3196},[376,21955,21956],{"class":1020}," IImageStorage",[376,21958,21959],{"class":1020}," _imageStorage",[376,21961,3428],{"class":393},[376,21963,21964,21966,21968,21971,21974],{"class":378,"line":552},[376,21965,20728],{"class":3196},[376,21967,12197],{"class":3196},[376,21969,21970],{"class":1020}," IChatRoomRepository",[376,21972,21973],{"class":1020}," _repository",[376,21975,3428],{"class":393},[376,21977,21978],{"class":378,"line":564},[376,21979,1036],{"emptyLinePlaceholder":1035},[376,21981,21982,21984,21987,21989,21991,21994,21996,21999,22002],{"class":378,"line":575},[376,21983,12192],{"class":3196},[376,21985,21986],{"class":1020}," AddImageMessageService",[376,21988,3220],{"class":393},[376,21990,21815],{"class":1020},[376,21992,21993],{"class":1020}," imageStorage",[376,21995,3188],{"class":393},[376,21997,21998],{"class":1020},"IChatRoomRepository",[376,22000,22001],{"class":1020}," repository",[376,22003,3319],{"class":393},[376,22005,22006],{"class":378,"line":583},[376,22007,5704],{"class":393},[376,22009,22010,22013,22015],{"class":378,"line":597},[376,22011,22012],{"class":393},"        _imageStorage ",[376,22014,3240],{"class":3196},[376,22016,22017],{"class":393}," imageStorage;\n",[376,22019,22020,22023,22025],{"class":378,"line":605},[376,22021,22022],{"class":393},"        _repository ",[376,22024,3240],{"class":3196},[376,22026,22027],{"class":393}," repository;\n",[376,22029,22030],{"class":378,"line":616},[376,22031,5766],{"class":393},[376,22033,22034],{"class":378,"line":624},[376,22035,1036],{"emptyLinePlaceholder":1035},[376,22037,22038,22040,22042,22044,22046,22048,22051,22054,22056,22058,22061,22063,22065,22068,22070,22072,22074],{"class":378,"line":635},[376,22039,12192],{"class":3196},[376,22041,3277],{"class":3196},[376,22043,3280],{"class":1020},[376,22045,18487],{"class":1020},[376,22047,3220],{"class":393},[376,22049,22050],{"class":1020},"Guid",[376,22052,22053],{"class":1020}," chatRoomId",[376,22055,3188],{"class":393},[376,22057,5012],{"class":3196},[376,22059,22060],{"class":1020}," prompt",[376,22062,3188],{"class":393},[376,22064,21910],{"class":1020},[376,22066,22067],{"class":1020}," image",[376,22069,3188],{"class":393},[376,22071,3307],{"class":1020},[376,22073,19061],{"class":1020},[376,22075,3319],{"class":393},[376,22077,22078],{"class":378,"line":5815},[376,22079,5704],{"class":393},[376,22081,22082,22084,22087,22089,22091,22094,22096],{"class":378,"line":7233},[376,22083,13379],{"class":3196},[376,22085,22086],{"class":1020}," chatRoom",[376,22088,3313],{"class":3196},[376,22090,9704],{"class":3196},[376,22092,22093],{"class":393}," _repository.",[376,22095,21428],{"class":1020},[376,22097,22098],{"class":393},"(chatRoomId, ct);\n",[376,22100,22101,22103,22106,22108,22110,22113,22115,22117,22120,22122,22124,22126,22128,22131,22134],{"class":378,"line":7243},[376,22102,13379],{"class":3196},[376,22104,22105],{"class":1020}," url",[376,22107,3313],{"class":3196},[376,22109,9704],{"class":3196},[376,22111,22112],{"class":393}," _imageStorage.",[376,22114,21496],{"class":1020},[376,22116,3220],{"class":393},[376,22118,22119],{"class":414},"$\"",[376,22121,4729],{"class":414},[376,22123,22050],{"class":393},[376,22125,4735],{"class":414},[376,22127,9344],{"class":1020},[376,22129,22130],{"class":414},"()}",[376,22132,22133],{"class":414},".png\"",[376,22135,22136],{"class":393},", image, ct);\n",[376,22138,22139],{"class":378,"line":7251},[376,22140,1036],{"emptyLinePlaceholder":1035},[376,22142,22143,22146,22149,22152],{"class":378,"line":7721},[376,22144,22145],{"class":393},"        chatRoom.",[376,22147,22148],{"class":1020},"AddImageMessage",[376,22150,22151],{"class":393},"(prompt, url);   ",[376,22153,22154],{"class":382},"\u002F\u002F 商業規則在聚合根裡\n",[376,22156,22157,22159,22161,22163],{"class":378,"line":7729},[376,22158,9572],{"class":3196},[376,22160,22093],{"class":393},[376,22162,21496],{"class":1020},[376,22164,22165],{"class":393},"(chatRoom, ct);\n",[376,22167,22168],{"class":378,"line":7740},[376,22169,5766],{"class":393},[376,22171,22172],{"class":378,"line":9569},[376,22173,3381],{"class":393},[28,22175,22177],{"id":22176},"同一個-port兩種-adapter","同一個 Port，兩種 Adapter",[11,22179,22180],{},"圖片儲存有兩種實作，各自放在獨立的專案：",[36,22182,22184],{"className":3145,"code":22183,"language":3147,"meta":44,"style":44},"\u002F\u002F Adapter：本機檔案系統（開發、測試環境）\npublic class LocalImageStorage : IImageStorage\n{\n    private readonly string _root;\n    public LocalImageStorage(IOptions\u003CLocalStorageOptions> options) => _root = options.Value.Path;\n\n    public async Task\u003Cstring> SaveAsync(string fileName, Stream content, CancellationToken ct)\n    {\n        var path = Path.Combine(_root, fileName);\n        await using var file = File.Create(path);\n        await content.CopyToAsync(file, ct);\n        return $\"\u002Fimages\u002F{fileName}\";\n    }\n}\n\n\u002F\u002F Adapter：AWS S3（正式環境）\npublic class S3ImageStorage : IImageStorage\n{\n    private readonly IAmazonS3 _s3;\n    private readonly string _bucket;\n\n    public S3ImageStorage(IAmazonS3 s3, IOptions\u003CS3StorageOptions> options)\n    {\n        _s3 = s3;\n        _bucket = options.Value.Bucket;\n    }\n\n    public async Task\u003Cstring> SaveAsync(string fileName, Stream content, CancellationToken ct)\n    {\n        await _s3.PutObjectAsync(new PutObjectRequest\n        {\n            BucketName = _bucket,\n            Key = fileName,\n            InputStream = content\n        }, ct);\n        return $\"https:\u002F\u002F{_bucket}.s3.amazonaws.com\u002F{fileName}\";\n    }\n}\n",[15,22185,22186,22191,22205,22209,22222,22254,22258,22294,22298,22316,22339,22352,22370,22374,22378,22382,22387,22400,22404,22418,22431,22435,22464,22468,22478,22488,22492,22496,22532,22536,22552,22556,22566,22576,22586,22591,22618,22622],{"__ignoreMap":44},[376,22187,22188],{"class":378,"line":379},[376,22189,22190],{"class":382},"\u002F\u002F Adapter：本機檔案系統（開發、測試環境）\n",[376,22192,22193,22195,22197,22200,22202],{"class":378,"line":386},[376,22194,3274],{"class":3196},[376,22196,12180],{"class":3196},[376,22198,22199],{"class":1020}," LocalImageStorage",[376,22201,18523],{"class":393},[376,22203,22204],{"class":1020},"IImageStorage\n",[376,22206,22207],{"class":378,"line":397},[376,22208,3324],{"class":393},[376,22210,22211,22213,22215,22217,22220],{"class":378,"line":405},[376,22212,20728],{"class":3196},[376,22214,12197],{"class":3196},[376,22216,13098],{"class":3196},[376,22218,22219],{"class":1020}," _root",[376,22221,3428],{"class":393},[376,22223,22224,22226,22228,22230,22233,22235,22238,22240,22242,22244,22246,22249,22251],{"class":378,"line":418},[376,22225,12192],{"class":3196},[376,22227,22199],{"class":1020},[376,22229,3220],{"class":393},[376,22231,22232],{"class":1020},"IOptions",[376,22234,3176],{"class":393},[376,22236,22237],{"class":1020},"LocalStorageOptions",[376,22239,3288],{"class":393},[376,22241,17398],{"class":1020},[376,22243,5662],{"class":393},[376,22245,5665],{"class":3196},[376,22247,22248],{"class":393}," _root ",[376,22250,3240],{"class":3196},[376,22252,22253],{"class":393}," options.Value.Path;\n",[376,22255,22256],{"class":378,"line":429},[376,22257,1036],{"emptyLinePlaceholder":1035},[376,22259,22260,22262,22264,22266,22268,22270,22272,22274,22276,22278,22280,22282,22284,22286,22288,22290,22292],{"class":378,"line":437},[376,22261,12192],{"class":3196},[376,22263,3277],{"class":3196},[376,22265,3280],{"class":1020},[376,22267,3176],{"class":393},[376,22269,5012],{"class":3196},[376,22271,3288],{"class":393},[376,22273,21496],{"class":1020},[376,22275,3220],{"class":393},[376,22277,5012],{"class":3196},[376,22279,21905],{"class":1020},[376,22281,3188],{"class":393},[376,22283,21910],{"class":1020},[376,22285,21913],{"class":1020},[376,22287,3188],{"class":393},[376,22289,3307],{"class":1020},[376,22291,19061],{"class":1020},[376,22293,3319],{"class":393},[376,22295,22296],{"class":378,"line":447},[376,22297,5704],{"class":393},[376,22299,22300,22302,22305,22307,22310,22313],{"class":378,"line":531},[376,22301,13379],{"class":3196},[376,22303,22304],{"class":1020}," path",[376,22306,3313],{"class":3196},[376,22308,22309],{"class":393}," Path.",[376,22311,22312],{"class":1020},"Combine",[376,22314,22315],{"class":393},"(_root, fileName);\n",[376,22317,22318,22320,22323,22325,22328,22330,22333,22336],{"class":378,"line":542},[376,22319,9572],{"class":3196},[376,22321,22322],{"class":3196}," using",[376,22324,9422],{"class":3196},[376,22326,22327],{"class":1020}," file",[376,22329,3313],{"class":3196},[376,22331,22332],{"class":393}," File.",[376,22334,22335],{"class":1020},"Create",[376,22337,22338],{"class":393},"(path);\n",[376,22340,22341,22343,22346,22349],{"class":378,"line":552},[376,22342,9572],{"class":3196},[376,22344,22345],{"class":393}," content.",[376,22347,22348],{"class":1020},"CopyToAsync",[376,22350,22351],{"class":393},"(file, ct);\n",[376,22353,22354,22356,22359,22361,22364,22366,22368],{"class":378,"line":564},[376,22355,3485],{"class":3196},[376,22357,22358],{"class":414}," $\"\u002Fimages\u002F",[376,22360,4729],{"class":414},[376,22362,22363],{"class":393},"fileName",[376,22365,4741],{"class":414},[376,22367,4744],{"class":414},[376,22369,3428],{"class":393},[376,22371,22372],{"class":378,"line":575},[376,22373,5766],{"class":393},[376,22375,22376],{"class":378,"line":583},[376,22377,3381],{"class":393},[376,22379,22380],{"class":378,"line":597},[376,22381,1036],{"emptyLinePlaceholder":1035},[376,22383,22384],{"class":378,"line":605},[376,22385,22386],{"class":382},"\u002F\u002F Adapter：AWS S3（正式環境）\n",[376,22388,22389,22391,22393,22396,22398],{"class":378,"line":616},[376,22390,3274],{"class":3196},[376,22392,12180],{"class":3196},[376,22394,22395],{"class":1020}," S3ImageStorage",[376,22397,18523],{"class":393},[376,22399,22204],{"class":1020},[376,22401,22402],{"class":378,"line":624},[376,22403,3324],{"class":393},[376,22405,22406,22408,22410,22413,22416],{"class":378,"line":635},[376,22407,20728],{"class":3196},[376,22409,12197],{"class":3196},[376,22411,22412],{"class":1020}," IAmazonS3",[376,22414,22415],{"class":1020}," _s3",[376,22417,3428],{"class":393},[376,22419,22420,22422,22424,22426,22429],{"class":378,"line":5815},[376,22421,20728],{"class":3196},[376,22423,12197],{"class":3196},[376,22425,13098],{"class":3196},[376,22427,22428],{"class":1020}," _bucket",[376,22430,3428],{"class":393},[376,22432,22433],{"class":378,"line":7233},[376,22434,1036],{"emptyLinePlaceholder":1035},[376,22436,22437,22439,22441,22443,22446,22449,22451,22453,22455,22458,22460,22462],{"class":378,"line":7243},[376,22438,12192],{"class":3196},[376,22440,22395],{"class":1020},[376,22442,3220],{"class":393},[376,22444,22445],{"class":1020},"IAmazonS3",[376,22447,22448],{"class":1020}," s3",[376,22450,3188],{"class":393},[376,22452,22232],{"class":1020},[376,22454,3176],{"class":393},[376,22456,22457],{"class":1020},"S3StorageOptions",[376,22459,3288],{"class":393},[376,22461,17398],{"class":1020},[376,22463,3319],{"class":393},[376,22465,22466],{"class":378,"line":7251},[376,22467,5704],{"class":393},[376,22469,22470,22473,22475],{"class":378,"line":7721},[376,22471,22472],{"class":393},"        _s3 ",[376,22474,3240],{"class":3196},[376,22476,22477],{"class":393}," s3;\n",[376,22479,22480,22483,22485],{"class":378,"line":7729},[376,22481,22482],{"class":393},"        _bucket ",[376,22484,3240],{"class":3196},[376,22486,22487],{"class":393}," options.Value.Bucket;\n",[376,22489,22490],{"class":378,"line":7740},[376,22491,5766],{"class":393},[376,22493,22494],{"class":378,"line":9569},[376,22495,1036],{"emptyLinePlaceholder":1035},[376,22497,22498,22500,22502,22504,22506,22508,22510,22512,22514,22516,22518,22520,22522,22524,22526,22528,22530],{"class":378,"line":9581},[376,22499,12192],{"class":3196},[376,22501,3277],{"class":3196},[376,22503,3280],{"class":1020},[376,22505,3176],{"class":393},[376,22507,5012],{"class":3196},[376,22509,3288],{"class":393},[376,22511,21496],{"class":1020},[376,22513,3220],{"class":393},[376,22515,5012],{"class":3196},[376,22517,21905],{"class":1020},[376,22519,3188],{"class":393},[376,22521,21910],{"class":1020},[376,22523,21913],{"class":1020},[376,22525,3188],{"class":393},[376,22527,3307],{"class":1020},[376,22529,19061],{"class":1020},[376,22531,3319],{"class":393},[376,22533,22534],{"class":378,"line":9590},[376,22535,5704],{"class":393},[376,22537,22538,22540,22543,22545,22547,22549],{"class":378,"line":9595},[376,22539,9572],{"class":3196},[376,22541,22542],{"class":393}," _s3.",[376,22544,21747],{"class":1020},[376,22546,3220],{"class":393},[376,22548,3197],{"class":3196},[376,22550,22551],{"class":1020}," PutObjectRequest\n",[376,22553,22554],{"class":378,"line":9600},[376,22555,5721],{"class":393},[376,22557,22558,22561,22563],{"class":378,"line":9605},[376,22559,22560],{"class":393},"            BucketName ",[376,22562,3240],{"class":3196},[376,22564,22565],{"class":393}," _bucket,\n",[376,22567,22568,22571,22573],{"class":378,"line":9615},[376,22569,22570],{"class":393},"            Key ",[376,22572,3240],{"class":3196},[376,22574,22575],{"class":393}," fileName,\n",[376,22577,22578,22581,22583],{"class":378,"line":9639},[376,22579,22580],{"class":393},"            InputStream ",[376,22582,3240],{"class":3196},[376,22584,22585],{"class":393}," content\n",[376,22587,22588],{"class":378,"line":9651},[376,22589,22590],{"class":393},"        }, ct);\n",[376,22592,22593,22595,22598,22600,22603,22605,22608,22610,22612,22614,22616],{"class":378,"line":9656},[376,22594,3485],{"class":3196},[376,22596,22597],{"class":414}," $\"https:\u002F\u002F",[376,22599,4729],{"class":414},[376,22601,22602],{"class":393},"_bucket",[376,22604,4741],{"class":414},[376,22606,22607],{"class":414},".s3.amazonaws.com\u002F",[376,22609,4729],{"class":414},[376,22611,22363],{"class":393},[376,22613,4741],{"class":414},[376,22615,4744],{"class":414},[376,22617,3428],{"class":393},[376,22619,22620],{"class":378,"line":13527},[376,22621,5766],{"class":393},[376,22623,22624],{"class":378,"line":13532},[376,22625,3381],{"class":393},[11,22627,22628,22629,55],{},"要用哪一個，",[23,22630,22631],{},"只在程式進入點決定",[36,22633,22635],{"className":3145,"code":22634,"language":3147,"meta":44,"style":44},"if (builder.Configuration[\"AWS_BUCKET\"] is not null)\n    builder.Services.AddS3ImageStorage(builder.Configuration);\nelse\n    builder.Services.AddLocalImageStorage(builder.Configuration);\n",[15,22636,22637,22659,22670,22674],{"__ignoreMap":44},[376,22638,22639,22642,22645,22648,22651,22653,22655,22657],{"class":378,"line":379},[376,22640,22641],{"class":3196},"if",[376,22643,22644],{"class":393}," (builder.Configuration[",[376,22646,22647],{"class":414},"\"AWS_BUCKET\"",[376,22649,22650],{"class":393},"] ",[376,22652,12611],{"class":3196},[376,22654,12614],{"class":3196},[376,22656,12617],{"class":560},[376,22658,3319],{"class":393},[376,22660,22661,22664,22667],{"class":378,"line":386},[376,22662,22663],{"class":393},"    builder.Services.",[376,22665,22666],{"class":1020},"AddS3ImageStorage",[376,22668,22669],{"class":393},"(builder.Configuration);\n",[376,22671,22672],{"class":378,"line":397},[376,22673,9159],{"class":3196},[376,22675,22676,22678,22681],{"class":378,"line":405},[376,22677,22663],{"class":393},[376,22679,22680],{"class":1020},"AddLocalImageStorage",[376,22682,22669],{"class":393},[11,22684,22685,22686,22689,22690,114],{},"換儲存方式時，",[15,22687,22688],{},"AddImageMessageService"," 和其他所有業務程式",[23,22691,22692],{},"一行都不用改",[28,22694,22695],{"id":22695},"專案怎麼切",[11,22697,22698],{},"用專案（csproj）的參考關係，讓編譯器幫忙守住依賴方向：",[36,22700,22703],{"className":22701,"code":22702,"language":41,"meta":44},[39],"NexusGPT.Entities                         ← Domain：聚合根、領域事件（不參考任何專案）\nNexusGPT.UseCase                          ← Application：UseCase + Port 介面（只參考 Entities）\nNexusGPT.Adapter.Out                      ← Driven Adapter：EF Core Repository（參考 UseCase）\nNexusGPT.Adapter.Out.ImageStorage.Local   ← Driven Adapter：本機圖片儲存\nNexusGPT.Adapter.Out.ImageStorage.S3      ← Driven Adapter：S3 圖片儲存\nNexusGPT.WebApplication                   ← Driving Adapter + 組裝：Controller、Hub、DI 註冊\n",[15,22704,22702],{"__ignoreMap":44},[11,22706,22707,22710,22711,14372,22714,22717],{},[15,22708,22709],{},"NexusGPT.UseCase"," 不參考任何 Adapter 專案，所以在 UseCase 裡",[23,22712,22713],{},"根本寫不出",[15,22715,22716],{},"new AmazonS3Client()","——編譯器會直接報錯。比起靠 Code Review 提醒「不要在業務邏輯裡呼叫 SDK」，讓專案參考關係擋住要可靠得多。",[11,22719,22720,22721,22724,22725,114],{},"只有 ",[15,22722,22723],{},"WebApplication"," 這個最外層的專案會參考所有其他專案，負責把 Adapter 組裝起來，這個位置也稱為 ",[23,22726,22727],{},"Composition Root",[28,22729,22730],{"id":22730},"帶來的好處",[11,22732,22733,22736],{},[23,22734,22735],{},"測試變簡單","：UseCase 只依賴介面，單元測試直接給假的實作，不需要資料庫、S3、OpenAI：",[36,22738,22740],{"className":3145,"code":22739,"language":3147,"meta":44,"style":44},"[Fact]\npublic async Task HandleAsync_圖片訊息_訊息會帶有儲存後的圖片網址()\n{\n    var storage = Substitute.For\u003CIImageStorage>();\n    storage.SaveAsync(default!, default!, default).ReturnsForAnyArgs(\"https:\u002F\u002Fimg\u002F1.png\");\n    var repository = new InMemoryChatRoomRepository(ChatRoom.Create(\"新對話\"));\n\n    var sut = new AddImageMessageService(storage, repository);\n    await sut.HandleAsync(repository.Id, \"一隻貓\", Stream.Null, CancellationToken.None);\n\n    repository.Saved.Messages.Last().ImageUrl.Should().Be(\"https:\u002F\u002Fimg\u002F1.png\");\n}\n",[15,22741,22742,22752,22765,22769,22790,22823,22848,22852,22868,22886,22890,22915],{"__ignoreMap":44},[376,22743,22744,22747,22750],{"class":378,"line":379},[376,22745,22746],{"class":393},"[",[376,22748,22749],{"class":1020},"Fact",[376,22751,7718],{"class":393},[376,22753,22754,22756,22758,22760,22763],{"class":378,"line":386},[376,22755,3274],{"class":3196},[376,22757,3277],{"class":3196},[376,22759,3280],{"class":1020},[376,22761,22762],{"class":1020}," HandleAsync_圖片訊息_訊息會帶有儲存後的圖片網址",[376,22764,3165],{"class":393},[376,22766,22767],{"class":378,"line":397},[376,22768,3324],{"class":393},[376,22770,22771,22773,22776,22778,22781,22784,22786,22788],{"class":378,"line":405},[376,22772,5061],{"class":3196},[376,22774,22775],{"class":1020}," storage",[376,22777,3313],{"class":3196},[376,22779,22780],{"class":393}," Substitute.",[376,22782,22783],{"class":1020},"For",[376,22785,3176],{"class":393},[376,22787,21815],{"class":1020},[376,22789,18601],{"class":393},[376,22791,22792,22795,22797,22799,22802,22804,22806,22808,22810,22813,22816,22818,22821],{"class":378,"line":418},[376,22793,22794],{"class":393},"    storage.",[376,22796,21496],{"class":1020},[376,22798,3220],{"class":393},[376,22800,22801],{"class":3196},"default!",[376,22803,3188],{"class":393},[376,22805,22801],{"class":3196},[376,22807,3188],{"class":393},[376,22809,14815],{"class":3196},[376,22811,22812],{"class":393},").",[376,22814,22815],{"class":1020},"ReturnsForAnyArgs",[376,22817,3220],{"class":393},[376,22819,22820],{"class":414},"\"https:\u002F\u002Fimg\u002F1.png\"",[376,22822,3376],{"class":393},[376,22824,22825,22827,22829,22831,22833,22836,22839,22841,22843,22846],{"class":378,"line":429},[376,22826,5061],{"class":3196},[376,22828,22001],{"class":1020},[376,22830,3313],{"class":3196},[376,22832,4692],{"class":3196},[376,22834,22835],{"class":1020}," InMemoryChatRoomRepository",[376,22837,22838],{"class":393},"(ChatRoom.",[376,22840,22335],{"class":1020},[376,22842,3220],{"class":393},[376,22844,22845],{"class":414},"\"新對話\"",[376,22847,3839],{"class":393},[376,22849,22850],{"class":378,"line":437},[376,22851,1036],{"emptyLinePlaceholder":1035},[376,22853,22854,22856,22859,22861,22863,22865],{"class":378,"line":447},[376,22855,5061],{"class":3196},[376,22857,22858],{"class":1020}," sut",[376,22860,3313],{"class":3196},[376,22862,4692],{"class":3196},[376,22864,21986],{"class":1020},[376,22866,22867],{"class":393},"(storage, repository);\n",[376,22869,22870,22872,22875,22877,22880,22883],{"class":378,"line":531},[376,22871,3329],{"class":3196},[376,22873,22874],{"class":393}," sut.",[376,22876,19138],{"class":1020},[376,22878,22879],{"class":393},"(repository.Id, ",[376,22881,22882],{"class":414},"\"一隻貓\"",[376,22884,22885],{"class":393},", Stream.Null, CancellationToken.None);\n",[376,22887,22888],{"class":378,"line":542},[376,22889,1036],{"emptyLinePlaceholder":1035},[376,22891,22892,22895,22898,22901,22904,22906,22909,22911,22913],{"class":378,"line":552},[376,22893,22894],{"class":393},"    repository.Saved.Messages.",[376,22896,22897],{"class":1020},"Last",[376,22899,22900],{"class":393},"().ImageUrl.",[376,22902,22903],{"class":1020},"Should",[376,22905,3526],{"class":393},[376,22907,22908],{"class":1020},"Be",[376,22910,3220],{"class":393},[376,22912,22820],{"class":414},[376,22914,3376],{"class":393},[376,22916,22917],{"class":378,"line":564},[376,22918,3381],{"class":393},[11,22920,22921,22924,22925,22927],{},[23,22922,22923],{},"技術決策可以延後、可以反悔","：一開始用 SQL Server，之後想支援 DynamoDB 或 MongoDB，只要為 ",[15,22926,21998],{}," 多寫一個 Adapter。",[11,22929,22930,22933],{},[23,22931,22932],{},"業務邏輯集中","：商業規則都在核心裡，閱讀程式碼時不會被 SDK 的細節淹沒。",[28,22935,22936],{"id":22936},"什麼時候不需要",[11,22938,22939,22940,22943,22944,22947],{},"六角形架構會讓專案數量和介面數量變多。如果系統只是單純的 CRUD、沒有什麼商業規則、外部依賴也不會更換，一個專案加上清楚的資料夾分層就夠了。它最有價值的地方，是",[23,22941,22942],{},"商業規則複雜","、或",[23,22945,22946],{},"外部依賴明確需要抽換","（不同環境、不同客戶、不同雲端）的系統。",[28,22949,22951],{"id":22950},"使用情境內部-gpt-平台的開源版本","使用情境：內部 GPT 平台的開源版本",[11,22953,22954],{},"公司內部 GPT 平台的核心程式碼整理成開源專案時，就是以六角形架構切分：",[66,22956,22957,22960,22963],{},[69,22958,22959],{},"聊天室、訊息、圖片訊息的商業規則放在 Domain 與 UseCase",[69,22961,22962],{},"資料庫存取、圖片儲存、OpenAI 呼叫都是 Driven Adapter",[69,22964,22965],{},"圖片儲存提供 Local 與 S3 兩種 Adapter：本機開發掛載一個資料夾就能跑，正式環境設定 S3 的環境變數即可，Docker 映像完全相同",[28,22967,1890],{"id":1890},[11,22969,22970],{},"六角形架構把系統分成「只有業務的核心」和「接外部技術的 Adapter」，兩者之間用核心定義的 Port（介面）隔開，所有依賴都指向核心；再用專案參考關係讓編譯器守住這個方向，就能做到換資料庫、換儲存、換外部服務都不必修改業務程式，單元測試也不再需要任何外部環境。",[1132,22972,22973],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}",{"title":44,"searchDepth":386,"depth":386,"links":22975},[22976,22977,22978,22979,22980,22981,22982,22983,22984],{"id":21725,"depth":386,"text":21725},{"id":21756,"depth":386,"text":21757},{"id":21838,"depth":386,"text":21839},{"id":22176,"depth":386,"text":22177},{"id":22695,"depth":386,"text":22695},{"id":22730,"depth":386,"text":22730},{"id":22936,"depth":386,"text":22936},{"id":22950,"depth":386,"text":22951},{"id":1890,"depth":386,"text":1890},"2023-12-19",{},{"title":21720,"description":44},"articles\u002Fhexagonal-architecture-dotnet",[22990,22991,10104,3037,8964],"Hexagonal Architecture","Clean Architecture","qkJBhBszATTq8XMm0hwBG1uwmtISRljRLSi5hvNEGQc",{"id":22994,"title":22995,"body":22996,"date":25183,"description":25184,"excerpt":1156,"extension":1157,"meta":25185,"navigation":1035,"path":25186,"seo":25187,"stem":25188,"tags":25189,"__hash__":25193},"articles\u002Farticles\u002Funit-test-aaa-naming.md","單元測試怎麼寫才好讀？用 3A 模式組織測試，用「方法_情境_預期結果」命名",{"type":8,"value":22997,"toc":25168},[22998,23012,23015,23029,23032,23036,23039,23104,23108,23115,23430,23436,23458,23462,23468,23874,23877,23897,23904,24039,24046,24050,24057,24230,24246,24250,24253,24494,24522,24529,24533,24571,24575,24578,24584,24591,24698,24708,24712,24715,24735,24746,24749,24768,24781,24807,24810,24813,24827,24900,24910,25068,25074,25127,25130,25162,25165],[11,22999,23000,23001,23004,23005,2276,23008,23011],{},"寫單元測試時，最常遇到的問題通常不是「不會寫」，而是",[23,23002,23003],{},"三個月後回來看，看不懂當初在測什麼","。測試失敗時，紅燈只告訴你一個方法名稱；如果名稱是 ",[15,23006,23007],{},"Test1",[15,23009,23010],{},"CreateOrderTest2","，還得把整段測試讀完、甚至 debug，才知道是哪個業務規則壞了。",[11,23013,23014],{},"好讀的測試靠兩件事撐起來：",[211,23016,23017,23023],{},[69,23018,23019,23022],{},[23,23020,23021],{},"結構","：每個測試都用同樣的骨架寫——這就是 3A 模式。",[69,23024,23025,23028],{},[23,23026,23027],{},"命名","：方法名稱本身就是一句規格說明——「這個方法，在這個情境下，應該得到這個結果」。",[11,23030,23031],{},"以下用我在電商專案（Clean Architecture 的訂單服務）裡實際寫的測試當例子。",[28,23033,23035],{"id":23034},"_3a-模式arrangeactassert","3A 模式：Arrange、Act、Assert",[11,23037,23038],{},"3A 把一個測試切成三段，每段只做一件事：",[132,23040,23041,23054],{},[135,23042,23043],{},[138,23044,23045,23048,23051],{},[141,23046,23047],{"align":143},"階段",[141,23049,23050],{"align":143},"做什麼",[141,23052,23053],{"align":143},"常見內容",[152,23055,23056,23069,23091],{},[138,23057,23058,23063,23066],{},[157,23059,23060],{"align":143},[23,23061,23062],{},"Arrange（準備）",[157,23064,23065],{"align":143},"建立被測物件與它需要的環境",[157,23067,23068],{"align":143},"建立測試資料、設定 Stub 回傳值、固定時間",[138,23070,23071,23076,23083],{},[157,23072,23073],{"align":143},[23,23074,23075],{},"Act（執行）",[157,23077,23078,23079,23082],{"align":143},"呼叫",[23,23080,23081],{},"一次","被測的方法",[157,23084,23085,2276,23088],{"align":143},[15,23086,23087],{},"sut.HandleAsync(...)",[15,23089,23090],{},"order.Complete()",[138,23092,23093,23098,23101],{},[157,23094,23095],{"align":143},[23,23096,23097],{},"Assert（驗證）",[157,23099,23100],{"align":143},"檢查結果是否符合預期",[157,23102,23103],{"align":143},"回傳值、物件狀態、是否拋出例外、相依物件是否被呼叫",[100,23105,23107],{"id":23106},"範例一entity-的狀態變化","範例一：Entity 的狀態變化",[11,23109,23110,23111,23114],{},"先看最單純的情況——驗證 ",[15,23112,23113],{},"Order"," 這個 Entity 完成訂單後狀態是否正確：",[36,23116,23118],{"className":3145,"code":23117,"language":3147,"meta":44,"style":44},"[Fact]\npublic void Complete_訂單已完成_應更新訂單狀態為已完成()\n{\n    \u002F\u002F Arrange\n    var id = OrderId.New(Guid.NewGuid());\n    var memberId = MemberId.New(Guid.NewGuid());\n    var orderItems = new List\u003COrderItem>\n    {\n        new(ProductId.New(Guid.NewGuid()), 1, 100),\n        new(ProductId.New(Guid.NewGuid()), 2, 200),\n    };\n    var timeProvider = new FakeTimeProvider();\n    timeProvider.SetUtcNow(new DateTimeOffset(2023, 11, 01, 0, 0, 0, TimeSpan.Zero));\n    var shipmentAddress = new ShipmentAddress(\"台灣\", \"台北市\", \"大安區敦化南路二段100號\", \"106\");\n\n    var order = Order.New(id, memberId, orderItems, shipmentAddress, timeProvider);\n\n    \u002F\u002F Act\n    order.Complete();\n\n    \u002F\u002F Assert\n    order.Status.Should().Be(Status.Completed);\n}\n",[15,23119,23120,23128,23139,23143,23148,23168,23188,23209,23213,23239,23262,23267,23283,23327,23363,23367,23384,23388,23393,23403,23407,23412,23426],{"__ignoreMap":44},[376,23121,23122,23124,23126],{"class":378,"line":379},[376,23123,22746],{"class":393},[376,23125,22749],{"class":1020},[376,23127,7718],{"class":393},[376,23129,23130,23132,23134,23137],{"class":378,"line":386},[376,23131,3274],{"class":3196},[376,23133,21012],{"class":3196},[376,23135,23136],{"class":1020}," Complete_訂單已完成_應更新訂單狀態為已完成",[376,23138,3165],{"class":393},[376,23140,23141],{"class":378,"line":397},[376,23142,3324],{"class":393},[376,23144,23145],{"class":378,"line":405},[376,23146,23147],{"class":382},"    \u002F\u002F Arrange\n",[376,23149,23150,23152,23154,23156,23159,23161,23164,23166],{"class":378,"line":418},[376,23151,5061],{"class":3196},[376,23153,12502],{"class":1020},[376,23155,3313],{"class":3196},[376,23157,23158],{"class":393}," OrderId.",[376,23160,20965],{"class":1020},[376,23162,23163],{"class":393},"(Guid.",[376,23165,9344],{"class":1020},[376,23167,12018],{"class":393},[376,23169,23170,23172,23175,23177,23180,23182,23184,23186],{"class":378,"line":429},[376,23171,5061],{"class":3196},[376,23173,23174],{"class":1020}," memberId",[376,23176,3313],{"class":3196},[376,23178,23179],{"class":393}," MemberId.",[376,23181,20965],{"class":1020},[376,23183,23163],{"class":393},[376,23185,9344],{"class":1020},[376,23187,12018],{"class":393},[376,23189,23190,23192,23195,23197,23199,23202,23204,23207],{"class":378,"line":437},[376,23191,5061],{"class":3196},[376,23193,23194],{"class":1020}," orderItems",[376,23196,3313],{"class":3196},[376,23198,4692],{"class":3196},[376,23200,23201],{"class":1020}," List",[376,23203,3176],{"class":393},[376,23205,23206],{"class":1020},"OrderItem",[376,23208,18475],{"class":393},[376,23210,23211],{"class":378,"line":447},[376,23212,5704],{"class":393},[376,23214,23215,23218,23221,23223,23225,23227,23230,23232,23234,23236],{"class":378,"line":531},[376,23216,23217],{"class":3196},"        new",[376,23219,23220],{"class":393},"(ProductId.",[376,23222,20965],{"class":1020},[376,23224,23163],{"class":393},[376,23226,9344],{"class":1020},[376,23228,23229],{"class":393},"()), ",[376,23231,892],{"class":560},[376,23233,3188],{"class":393},[376,23235,5022],{"class":560},[376,23237,23238],{"class":393},"),\n",[376,23240,23241,23243,23245,23247,23249,23251,23253,23255,23257,23260],{"class":378,"line":542},[376,23242,23217],{"class":3196},[376,23244,23220],{"class":393},[376,23246,20965],{"class":1020},[376,23248,23163],{"class":393},[376,23250,9344],{"class":1020},[376,23252,23229],{"class":393},[376,23254,347],{"class":560},[376,23256,3188],{"class":393},[376,23258,23259],{"class":560},"200",[376,23261,23238],{"class":393},[376,23263,23264],{"class":378,"line":552},[376,23265,23266],{"class":393},"    };\n",[376,23268,23269,23271,23274,23276,23278,23281],{"class":378,"line":564},[376,23270,5061],{"class":3196},[376,23272,23273],{"class":1020}," timeProvider",[376,23275,3313],{"class":3196},[376,23277,4692],{"class":3196},[376,23279,23280],{"class":1020}," FakeTimeProvider",[376,23282,3532],{"class":393},[376,23284,23285,23288,23291,23293,23295,23297,23299,23302,23304,23307,23309,23312,23314,23316,23318,23320,23322,23324],{"class":378,"line":575},[376,23286,23287],{"class":393},"    timeProvider.",[376,23289,23290],{"class":1020},"SetUtcNow",[376,23292,3220],{"class":393},[376,23294,3197],{"class":3196},[376,23296,20687],{"class":1020},[376,23298,3220],{"class":393},[376,23300,23301],{"class":560},"2023",[376,23303,3188],{"class":393},[376,23305,23306],{"class":560},"11",[376,23308,3188],{"class":393},[376,23310,23311],{"class":560},"01",[376,23313,3188],{"class":393},[376,23315,4975],{"class":560},[376,23317,3188],{"class":393},[376,23319,4975],{"class":560},[376,23321,3188],{"class":393},[376,23323,4975],{"class":560},[376,23325,23326],{"class":393},", TimeSpan.Zero));\n",[376,23328,23329,23331,23334,23336,23338,23341,23343,23346,23348,23351,23353,23356,23358,23361],{"class":378,"line":583},[376,23330,5061],{"class":3196},[376,23332,23333],{"class":1020}," shipmentAddress",[376,23335,3313],{"class":3196},[376,23337,4692],{"class":3196},[376,23339,23340],{"class":1020}," ShipmentAddress",[376,23342,3220],{"class":393},[376,23344,23345],{"class":414},"\"台灣\"",[376,23347,3188],{"class":393},[376,23349,23350],{"class":414},"\"台北市\"",[376,23352,3188],{"class":393},[376,23354,23355],{"class":414},"\"大安區敦化南路二段100號\"",[376,23357,3188],{"class":393},[376,23359,23360],{"class":414},"\"106\"",[376,23362,3376],{"class":393},[376,23364,23365],{"class":378,"line":597},[376,23366,1036],{"emptyLinePlaceholder":1035},[376,23368,23369,23371,23374,23376,23379,23381],{"class":378,"line":605},[376,23370,5061],{"class":3196},[376,23372,23373],{"class":1020}," order",[376,23375,3313],{"class":3196},[376,23377,23378],{"class":393}," Order.",[376,23380,20965],{"class":1020},[376,23382,23383],{"class":393},"(id, memberId, orderItems, shipmentAddress, timeProvider);\n",[376,23385,23386],{"class":378,"line":616},[376,23387,1036],{"emptyLinePlaceholder":1035},[376,23389,23390],{"class":378,"line":624},[376,23391,23392],{"class":382},"    \u002F\u002F Act\n",[376,23394,23395,23398,23401],{"class":378,"line":635},[376,23396,23397],{"class":393},"    order.",[376,23399,23400],{"class":1020},"Complete",[376,23402,3532],{"class":393},[376,23404,23405],{"class":378,"line":5815},[376,23406,1036],{"emptyLinePlaceholder":1035},[376,23408,23409],{"class":378,"line":7233},[376,23410,23411],{"class":382},"    \u002F\u002F Assert\n",[376,23413,23414,23417,23419,23421,23423],{"class":378,"line":7243},[376,23415,23416],{"class":393},"    order.Status.",[376,23418,22903],{"class":1020},[376,23420,3526],{"class":393},[376,23422,22908],{"class":1020},[376,23424,23425],{"class":393},"(Status.Completed);\n",[376,23427,23428],{"class":378,"line":7251},[376,23429,3381],{"class":393},[11,23431,23432,23433,23435],{},"三段的分界非常清楚：Arrange 準備好一張「待付款」的訂單；Act 只有一行 ",[15,23434,23090],{},"；Assert 只驗證狀態。讀的人不用理解訂單怎麼建立，只要看 Act 跟 Assert 就知道這個測試的重點。",[11,23437,23438,23439,23446,23447,23450,23451,23454,23455,23457],{},"這裡有個值得注意的細節：",[23,23440,23441,23442,23445],{},"時間用 ",[15,23443,23444],{},"FakeTimeProvider"," 固定住","。訂單的 ",[15,23448,23449],{},"CreateTime"," 取決於「現在」，如果直接用 ",[15,23452,23453],{},"DateTime.Now","，測試結果就會隨執行時間改變。上個月剛發布的 .NET 8 新增了 ",[15,23456,21344],{}," 抽象，讓時間也能像其他相依一樣被注入、被替換。",[100,23459,23461],{"id":23460},"範例二use-case-與相依物件","範例二：Use Case 與相依物件",[11,23463,23464,23465,55],{},"UseCase 層的測試會牽涉到外部相依（Repository、Event Bus、會員服務……），這時 Arrange 的主要工作就變成",[23,23466,23467],{},"設定替身（Test Double）的行為",[36,23469,23471],{"className":3145,"code":23470,"language":3147,"meta":44,"style":44},"[Fact]\npublic async Task HandleAsyncTest_輸入符合預期的參數_傳回True()\n{\n    \u002F\u002F Arrange\n    _generatorIdOutPort.GenerateAsync().Returns(Guid.NewGuid());\n    _orderOutPort.SaveAsync(Arg.Any\u003COrder>()).Returns(true);\n    _timeProvider.SetUtcNow(new DateTimeOffset(new DateTime(2023, 11, 20)));\n\n    var memberId = MemberId.New(new Guid(\"1AC0AE5E-E07E-4782-9E0B-F391FDFF26B3\"));\n    _memberOutPort.IsExistAsync(memberId).Returns(true);\n\n    var shipmentAddress = new ShipmentAddress(\"台灣\", \"台北市\", \"大安區敦化南路二段100號\", \"106\");\n    var orderItems = new List\u003COrderItem>\n    {\n        new(ProductId.New(new Guid(\"072AD23F-396B-445B-8627-1A560755ADAB\")), 1, 10),\n    };\n\n    var sut = GetSystemUnderTest();\n\n    \u002F\u002F Act\n    var parameter = new CreatOrderParameter\n    {\n        MemberId = memberId,\n        OrderItems = orderItems,\n        ShipmentAddress = shipmentAddress\n    };\n    var actual = await sut.HandleAsync(parameter);\n\n    \u002F\u002F Assert\n    actual.Should().BeTrue();\n    await _domainEventBus.Received(1).DispatchDomainEventsAsync(Arg.Any\u003COrder>());\n}\n",[15,23472,23473,23481,23494,23498,23502,23521,23549,23584,23588,23614,23633,23637,23667,23685,23689,23719,23723,23727,23740,23744,23748,23762,23766,23776,23786,23796,23800,23818,23822,23826,23840,23870],{"__ignoreMap":44},[376,23474,23475,23477,23479],{"class":378,"line":379},[376,23476,22746],{"class":393},[376,23478,22749],{"class":1020},[376,23480,7718],{"class":393},[376,23482,23483,23485,23487,23489,23492],{"class":378,"line":386},[376,23484,3274],{"class":3196},[376,23486,3277],{"class":3196},[376,23488,3280],{"class":1020},[376,23490,23491],{"class":1020}," HandleAsyncTest_輸入符合預期的參數_傳回True",[376,23493,3165],{"class":393},[376,23495,23496],{"class":378,"line":397},[376,23497,3324],{"class":393},[376,23499,23500],{"class":378,"line":405},[376,23501,23147],{"class":382},[376,23503,23504,23507,23510,23512,23515,23517,23519],{"class":378,"line":418},[376,23505,23506],{"class":393},"    _generatorIdOutPort.",[376,23508,23509],{"class":1020},"GenerateAsync",[376,23511,3526],{"class":393},[376,23513,23514],{"class":1020},"Returns",[376,23516,23163],{"class":393},[376,23518,9344],{"class":1020},[376,23520,12018],{"class":393},[376,23522,23523,23526,23528,23531,23534,23536,23538,23541,23543,23545,23547],{"class":378,"line":429},[376,23524,23525],{"class":393},"    _orderOutPort.",[376,23527,21496],{"class":1020},[376,23529,23530],{"class":393},"(Arg.",[376,23532,23533],{"class":1020},"Any",[376,23535,3176],{"class":393},[376,23537,23113],{"class":1020},[376,23539,23540],{"class":393},">()).",[376,23542,23514],{"class":1020},[376,23544,3220],{"class":393},[376,23546,18747],{"class":560},[376,23548,3376],{"class":393},[376,23550,23551,23554,23556,23558,23560,23562,23564,23566,23569,23571,23573,23575,23577,23579,23582],{"class":378,"line":437},[376,23552,23553],{"class":393},"    _timeProvider.",[376,23555,23290],{"class":1020},[376,23557,3220],{"class":393},[376,23559,3197],{"class":3196},[376,23561,20687],{"class":1020},[376,23563,3220],{"class":393},[376,23565,3197],{"class":3196},[376,23567,23568],{"class":1020}," DateTime",[376,23570,3220],{"class":393},[376,23572,23301],{"class":560},[376,23574,3188],{"class":393},[376,23576,23306],{"class":560},[376,23578,3188],{"class":393},[376,23580,23581],{"class":560},"20",[376,23583,17478],{"class":393},[376,23585,23586],{"class":378,"line":447},[376,23587,1036],{"emptyLinePlaceholder":1035},[376,23589,23590,23592,23594,23596,23598,23600,23602,23604,23607,23609,23612],{"class":378,"line":531},[376,23591,5061],{"class":3196},[376,23593,23174],{"class":1020},[376,23595,3313],{"class":3196},[376,23597,23179],{"class":393},[376,23599,20965],{"class":1020},[376,23601,3220],{"class":393},[376,23603,3197],{"class":3196},[376,23605,23606],{"class":1020}," Guid",[376,23608,3220],{"class":393},[376,23610,23611],{"class":414},"\"1AC0AE5E-E07E-4782-9E0B-F391FDFF26B3\"",[376,23613,3839],{"class":393},[376,23615,23616,23619,23622,23625,23627,23629,23631],{"class":378,"line":542},[376,23617,23618],{"class":393},"    _memberOutPort.",[376,23620,23621],{"class":1020},"IsExistAsync",[376,23623,23624],{"class":393},"(memberId).",[376,23626,23514],{"class":1020},[376,23628,3220],{"class":393},[376,23630,18747],{"class":560},[376,23632,3376],{"class":393},[376,23634,23635],{"class":378,"line":552},[376,23636,1036],{"emptyLinePlaceholder":1035},[376,23638,23639,23641,23643,23645,23647,23649,23651,23653,23655,23657,23659,23661,23663,23665],{"class":378,"line":564},[376,23640,5061],{"class":3196},[376,23642,23333],{"class":1020},[376,23644,3313],{"class":3196},[376,23646,4692],{"class":3196},[376,23648,23340],{"class":1020},[376,23650,3220],{"class":393},[376,23652,23345],{"class":414},[376,23654,3188],{"class":393},[376,23656,23350],{"class":414},[376,23658,3188],{"class":393},[376,23660,23355],{"class":414},[376,23662,3188],{"class":393},[376,23664,23360],{"class":414},[376,23666,3376],{"class":393},[376,23668,23669,23671,23673,23675,23677,23679,23681,23683],{"class":378,"line":575},[376,23670,5061],{"class":3196},[376,23672,23194],{"class":1020},[376,23674,3313],{"class":3196},[376,23676,4692],{"class":3196},[376,23678,23201],{"class":1020},[376,23680,3176],{"class":393},[376,23682,23206],{"class":1020},[376,23684,18475],{"class":393},[376,23686,23687],{"class":378,"line":583},[376,23688,5704],{"class":393},[376,23690,23691,23693,23695,23697,23699,23701,23703,23705,23708,23711,23713,23715,23717],{"class":378,"line":597},[376,23692,23217],{"class":3196},[376,23694,23220],{"class":393},[376,23696,20965],{"class":1020},[376,23698,3220],{"class":393},[376,23700,3197],{"class":3196},[376,23702,23606],{"class":1020},[376,23704,3220],{"class":393},[376,23706,23707],{"class":414},"\"072AD23F-396B-445B-8627-1A560755ADAB\"",[376,23709,23710],{"class":393},")), ",[376,23712,892],{"class":560},[376,23714,3188],{"class":393},[376,23716,361],{"class":560},[376,23718,23238],{"class":393},[376,23720,23721],{"class":378,"line":605},[376,23722,23266],{"class":393},[376,23724,23725],{"class":378,"line":616},[376,23726,1036],{"emptyLinePlaceholder":1035},[376,23728,23729,23731,23733,23735,23738],{"class":378,"line":624},[376,23730,5061],{"class":3196},[376,23732,22858],{"class":1020},[376,23734,3313],{"class":3196},[376,23736,23737],{"class":1020}," GetSystemUnderTest",[376,23739,3532],{"class":393},[376,23741,23742],{"class":378,"line":635},[376,23743,1036],{"emptyLinePlaceholder":1035},[376,23745,23746],{"class":378,"line":5815},[376,23747,23392],{"class":382},[376,23749,23750,23752,23755,23757,23759],{"class":378,"line":7233},[376,23751,5061],{"class":3196},[376,23753,23754],{"class":1020}," parameter",[376,23756,3313],{"class":3196},[376,23758,4692],{"class":3196},[376,23760,23761],{"class":1020}," CreatOrderParameter\n",[376,23763,23764],{"class":378,"line":7243},[376,23765,5704],{"class":393},[376,23767,23768,23771,23773],{"class":378,"line":7251},[376,23769,23770],{"class":393},"        MemberId ",[376,23772,3240],{"class":3196},[376,23774,23775],{"class":393}," memberId,\n",[376,23777,23778,23781,23783],{"class":378,"line":7721},[376,23779,23780],{"class":393},"        OrderItems ",[376,23782,3240],{"class":3196},[376,23784,23785],{"class":393}," orderItems,\n",[376,23787,23788,23791,23793],{"class":378,"line":7729},[376,23789,23790],{"class":393},"        ShipmentAddress ",[376,23792,3240],{"class":3196},[376,23794,23795],{"class":393}," shipmentAddress\n",[376,23797,23798],{"class":378,"line":7740},[376,23799,23266],{"class":393},[376,23801,23802,23804,23807,23809,23811,23813,23815],{"class":378,"line":9569},[376,23803,5061],{"class":3196},[376,23805,23806],{"class":1020}," actual",[376,23808,3313],{"class":3196},[376,23810,9704],{"class":3196},[376,23812,22874],{"class":393},[376,23814,19138],{"class":1020},[376,23816,23817],{"class":393},"(parameter);\n",[376,23819,23820],{"class":378,"line":9581},[376,23821,1036],{"emptyLinePlaceholder":1035},[376,23823,23824],{"class":378,"line":9590},[376,23825,23411],{"class":382},[376,23827,23828,23831,23833,23835,23838],{"class":378,"line":9595},[376,23829,23830],{"class":393},"    actual.",[376,23832,22903],{"class":1020},[376,23834,3526],{"class":393},[376,23836,23837],{"class":1020},"BeTrue",[376,23839,3532],{"class":393},[376,23841,23842,23844,23847,23850,23852,23854,23856,23859,23861,23863,23865,23867],{"class":378,"line":9600},[376,23843,3329],{"class":3196},[376,23845,23846],{"class":393}," _domainEventBus.",[376,23848,23849],{"class":1020},"Received",[376,23851,3220],{"class":393},[376,23853,892],{"class":560},[376,23855,22812],{"class":393},[376,23857,23858],{"class":1020},"DispatchDomainEventsAsync",[376,23860,23530],{"class":393},[376,23862,23533],{"class":1020},[376,23864,3176],{"class":393},[376,23866,23113],{"class":1020},[376,23868,23869],{"class":393},">());\n",[376,23871,23872],{"class":378,"line":9605},[376,23873,3381],{"class":393},[11,23875,23876],{},"Assert 裡驗證了兩件事，分別對應兩種驗證方式：",[66,23878,23879,23888],{},[69,23880,23881,55,23884,23887],{},[15,23882,23883],{},"actual.Should().BeTrue()",[23,23885,23886],{},"狀態驗證","——看回傳值。",[69,23889,23890,55,23893,23896],{},[15,23891,23892],{},"_domainEventBus.Received(1)...",[23,23894,23895],{},"行為驗證","——看相依物件有沒有被正確呼叫。訂單建立成功後「應該發出 Domain Event」是業務規則的一部分，只看回傳值驗證不到。",[11,23898,23899,23900,23903],{},"反過來，失敗的情境就驗證「",[23,23901,23902],{},"不應該","發生什麼」：",[36,23905,23907],{"className":3145,"code":23906,"language":3147,"meta":44,"style":44},"[Fact]\npublic async Task HandleAsyncTest_儲存訂單失敗_應回傳False()\n{\n    \u002F\u002F Arrange\n    _orderOutPort.SaveAsync(Arg.Any\u003COrder>()).Returns(false);\n    \u002F\u002F ...其餘準備省略\n\n    \u002F\u002F Act\n    var actual = await sut.HandleAsync(parameter);\n\n    \u002F\u002F Assert\n    actual.Should().BeFalse();\n    await _domainEventBus.DidNotReceive().DispatchDomainEventsAsync(Arg.Any\u003COrder>());\n}\n",[15,23908,23909,23917,23930,23934,23938,23962,23967,23971,23975,23991,23995,23999,24012,24035],{"__ignoreMap":44},[376,23910,23911,23913,23915],{"class":378,"line":379},[376,23912,22746],{"class":393},[376,23914,22749],{"class":1020},[376,23916,7718],{"class":393},[376,23918,23919,23921,23923,23925,23928],{"class":378,"line":386},[376,23920,3274],{"class":3196},[376,23922,3277],{"class":3196},[376,23924,3280],{"class":1020},[376,23926,23927],{"class":1020}," HandleAsyncTest_儲存訂單失敗_應回傳False",[376,23929,3165],{"class":393},[376,23931,23932],{"class":378,"line":397},[376,23933,3324],{"class":393},[376,23935,23936],{"class":378,"line":405},[376,23937,23147],{"class":382},[376,23939,23940,23942,23944,23946,23948,23950,23952,23954,23956,23958,23960],{"class":378,"line":418},[376,23941,23525],{"class":393},[376,23943,21496],{"class":1020},[376,23945,23530],{"class":393},[376,23947,23533],{"class":1020},[376,23949,3176],{"class":393},[376,23951,23113],{"class":1020},[376,23953,23540],{"class":393},[376,23955,23514],{"class":1020},[376,23957,3220],{"class":393},[376,23959,5575],{"class":560},[376,23961,3376],{"class":393},[376,23963,23964],{"class":378,"line":429},[376,23965,23966],{"class":382},"    \u002F\u002F ...其餘準備省略\n",[376,23968,23969],{"class":378,"line":437},[376,23970,1036],{"emptyLinePlaceholder":1035},[376,23972,23973],{"class":378,"line":447},[376,23974,23392],{"class":382},[376,23976,23977,23979,23981,23983,23985,23987,23989],{"class":378,"line":531},[376,23978,5061],{"class":3196},[376,23980,23806],{"class":1020},[376,23982,3313],{"class":3196},[376,23984,9704],{"class":3196},[376,23986,22874],{"class":393},[376,23988,19138],{"class":1020},[376,23990,23817],{"class":393},[376,23992,23993],{"class":378,"line":542},[376,23994,1036],{"emptyLinePlaceholder":1035},[376,23996,23997],{"class":378,"line":552},[376,23998,23411],{"class":382},[376,24000,24001,24003,24005,24007,24010],{"class":378,"line":564},[376,24002,23830],{"class":393},[376,24004,22903],{"class":1020},[376,24006,3526],{"class":393},[376,24008,24009],{"class":1020},"BeFalse",[376,24011,3532],{"class":393},[376,24013,24014,24016,24018,24021,24023,24025,24027,24029,24031,24033],{"class":378,"line":575},[376,24015,3329],{"class":3196},[376,24017,23846],{"class":393},[376,24019,24020],{"class":1020},"DidNotReceive",[376,24022,3526],{"class":393},[376,24024,23858],{"class":1020},[376,24026,23530],{"class":393},[376,24028,23533],{"class":1020},[376,24030,3176],{"class":393},[376,24032,23113],{"class":1020},[376,24034,23869],{"class":393},[376,24036,24037],{"class":378,"line":583},[376,24038,3381],{"class":393},[11,24040,24041,24042,24045],{},"存檔失敗卻還發出「訂單已建立」的事件，下游就會處理一張根本不存在的訂單——",[15,24043,24044],{},"DidNotReceive()"," 守住的正是這種 bug。",[100,24047,24049],{"id":24048},"範例三驗證例外","範例三：驗證例外",[11,24051,24052,24053,24056],{},"預期會拋例外的測試，Act 階段要注意：",[23,24054,24055],{},"不能直接呼叫方法","，否則例外在 Act 就炸掉了，根本走不到 Assert。做法是把呼叫包成委派（delegate），交給 Assert 去執行：",[36,24058,24060],{"className":3145,"code":24059,"language":3147,"meta":44,"style":44},"[Fact]\npublic async Task HandleAsyncTest_輸入的會員Id不存在_拋出MemberNotFoundException()\n{\n    \u002F\u002F Arrange\n    var memberId = MemberId.New(new Guid(\"1AC0AE5E-E07E-4782-9E0B-F391FDFF26B3\"));\n    _memberOutPort.IsExistAsync(memberId).Returns(false);\n    var sut = GetSystemUnderTest();\n\n    \u002F\u002F Act\n    var actual = () => sut.HandleAsync(new CreatOrderParameter\n    {\n        MemberId = memberId,\n        OrderItems = orderItems,\n    });\n\n    \u002F\u002F Assert\n    await actual.Should().ThrowAsync\u003CMemberNotFoundException>();\n}\n",[15,24061,24062,24070,24083,24087,24091,24115,24131,24143,24147,24151,24173,24177,24185,24193,24197,24201,24205,24226],{"__ignoreMap":44},[376,24063,24064,24066,24068],{"class":378,"line":379},[376,24065,22746],{"class":393},[376,24067,22749],{"class":1020},[376,24069,7718],{"class":393},[376,24071,24072,24074,24076,24078,24081],{"class":378,"line":386},[376,24073,3274],{"class":3196},[376,24075,3277],{"class":3196},[376,24077,3280],{"class":1020},[376,24079,24080],{"class":1020}," HandleAsyncTest_輸入的會員Id不存在_拋出MemberNotFoundException",[376,24082,3165],{"class":393},[376,24084,24085],{"class":378,"line":397},[376,24086,3324],{"class":393},[376,24088,24089],{"class":378,"line":405},[376,24090,23147],{"class":382},[376,24092,24093,24095,24097,24099,24101,24103,24105,24107,24109,24111,24113],{"class":378,"line":418},[376,24094,5061],{"class":3196},[376,24096,23174],{"class":1020},[376,24098,3313],{"class":3196},[376,24100,23179],{"class":393},[376,24102,20965],{"class":1020},[376,24104,3220],{"class":393},[376,24106,3197],{"class":3196},[376,24108,23606],{"class":1020},[376,24110,3220],{"class":393},[376,24112,23611],{"class":414},[376,24114,3839],{"class":393},[376,24116,24117,24119,24121,24123,24125,24127,24129],{"class":378,"line":429},[376,24118,23618],{"class":393},[376,24120,23621],{"class":1020},[376,24122,23624],{"class":393},[376,24124,23514],{"class":1020},[376,24126,3220],{"class":393},[376,24128,5575],{"class":560},[376,24130,3376],{"class":393},[376,24132,24133,24135,24137,24139,24141],{"class":378,"line":437},[376,24134,5061],{"class":3196},[376,24136,22858],{"class":1020},[376,24138,3313],{"class":3196},[376,24140,23737],{"class":1020},[376,24142,3532],{"class":393},[376,24144,24145],{"class":378,"line":447},[376,24146,1036],{"emptyLinePlaceholder":1035},[376,24148,24149],{"class":378,"line":531},[376,24150,23392],{"class":382},[376,24152,24153,24155,24157,24159,24161,24163,24165,24167,24169,24171],{"class":378,"line":542},[376,24154,5061],{"class":3196},[376,24156,23806],{"class":1020},[376,24158,3313],{"class":3196},[376,24160,9455],{"class":393},[376,24162,5665],{"class":3196},[376,24164,22874],{"class":393},[376,24166,19138],{"class":1020},[376,24168,3220],{"class":393},[376,24170,3197],{"class":3196},[376,24172,23761],{"class":1020},[376,24174,24175],{"class":378,"line":552},[376,24176,5704],{"class":393},[376,24178,24179,24181,24183],{"class":378,"line":564},[376,24180,23770],{"class":393},[376,24182,3240],{"class":3196},[376,24184,23775],{"class":393},[376,24186,24187,24189,24191],{"class":378,"line":575},[376,24188,23780],{"class":393},[376,24190,3240],{"class":3196},[376,24192,23785],{"class":393},[376,24194,24195],{"class":378,"line":583},[376,24196,9554],{"class":393},[376,24198,24199],{"class":378,"line":597},[376,24200,1036],{"emptyLinePlaceholder":1035},[376,24202,24203],{"class":378,"line":605},[376,24204,23411],{"class":382},[376,24206,24207,24209,24212,24214,24216,24219,24221,24224],{"class":378,"line":616},[376,24208,3329],{"class":3196},[376,24210,24211],{"class":393}," actual.",[376,24213,22903],{"class":1020},[376,24215,3526],{"class":393},[376,24217,24218],{"class":1020},"ThrowAsync",[376,24220,3176],{"class":393},[376,24222,24223],{"class":1020},"MemberNotFoundException",[376,24225,18601],{"class":393},[376,24227,24228],{"class":378,"line":624},[376,24229,3381],{"class":393},[11,24231,24232,24233,6342,24236,24238,24239,24242,24243,24245],{},"驗證的是",[23,24234,24235],{},"具體的例外型別",[15,24237,24223],{},"），而不是籠統的 ",[15,24240,24241],{},"Exception","。這樣「會員不存在」跟「訂單項目為空」兩條規則才不會互相掩護——如果都只驗 ",[15,24244,24241],{},"，其中一條邏輯被拿掉、改由另一個錯誤拋出，測試依然會綠燈。",[100,24247,24249],{"id":24248},"把-arrange-的重複部分抽出來","把 Arrange 的重複部分抽出來",[11,24251,24252],{},"同一個測試類別裡，每個測試都要建立一樣的 Mock 跟被測物件。我習慣這樣分工：",[36,24254,24256],{"className":3145,"code":24255,"language":3147,"meta":44,"style":44},"public class CreateOrderServiceTest\n{\n    private readonly IGeneratorIdOutPort _generatorIdOutPort;\n    private readonly IOrderOutPort _orderOutPort;\n    private readonly IMemberOutPort _memberOutPort;\n    private readonly IDomainEventBus _domainEventBus;\n    private readonly FakeTimeProvider _timeProvider;\n\n    \u002F\u002F xUnit 每個測試都會 new 一次測試類別，所以每個測試拿到的都是全新的替身\n    public CreateOrderServiceTest()\n    {\n        _timeProvider = new FakeTimeProvider();\n        _generatorIdOutPort = Substitute.For\u003CIGeneratorIdOutPort>();\n        _orderOutPort = Substitute.For\u003CIOrderOutPort>();\n        _memberOutPort = Substitute.For\u003CIMemberOutPort>();\n        _domainEventBus = Substitute.For\u003CIDomainEventBus>();\n    }\n\n    private ICreateOrderService GetSystemUnderTest()\n    {\n        return new CreateOrderService(\n            _generatorIdOutPort, _timeProvider, _orderOutPort, _domainEventBus, _memberOutPort);\n    }\n}\n",[15,24257,24258,24267,24271,24285,24299,24313,24327,24340,24344,24349,24358,24362,24375,24393,24411,24429,24447,24451,24455,24466,24470,24481,24486,24490],{"__ignoreMap":44},[376,24259,24260,24262,24264],{"class":378,"line":379},[376,24261,3274],{"class":3196},[376,24263,12180],{"class":3196},[376,24265,24266],{"class":1020}," CreateOrderServiceTest\n",[376,24268,24269],{"class":378,"line":386},[376,24270,3324],{"class":393},[376,24272,24273,24275,24277,24280,24283],{"class":378,"line":397},[376,24274,20728],{"class":3196},[376,24276,12197],{"class":3196},[376,24278,24279],{"class":1020}," IGeneratorIdOutPort",[376,24281,24282],{"class":1020}," _generatorIdOutPort",[376,24284,3428],{"class":393},[376,24286,24287,24289,24291,24294,24297],{"class":378,"line":405},[376,24288,20728],{"class":3196},[376,24290,12197],{"class":3196},[376,24292,24293],{"class":1020}," IOrderOutPort",[376,24295,24296],{"class":1020}," _orderOutPort",[376,24298,3428],{"class":393},[376,24300,24301,24303,24305,24308,24311],{"class":378,"line":418},[376,24302,20728],{"class":3196},[376,24304,12197],{"class":3196},[376,24306,24307],{"class":1020}," IMemberOutPort",[376,24309,24310],{"class":1020}," _memberOutPort",[376,24312,3428],{"class":393},[376,24314,24315,24317,24319,24322,24325],{"class":378,"line":429},[376,24316,20728],{"class":3196},[376,24318,12197],{"class":3196},[376,24320,24321],{"class":1020}," IDomainEventBus",[376,24323,24324],{"class":1020}," _domainEventBus",[376,24326,3428],{"class":393},[376,24328,24329,24331,24333,24335,24338],{"class":378,"line":437},[376,24330,20728],{"class":3196},[376,24332,12197],{"class":3196},[376,24334,23280],{"class":1020},[376,24336,24337],{"class":1020}," _timeProvider",[376,24339,3428],{"class":393},[376,24341,24342],{"class":378,"line":447},[376,24343,1036],{"emptyLinePlaceholder":1035},[376,24345,24346],{"class":378,"line":531},[376,24347,24348],{"class":382},"    \u002F\u002F xUnit 每個測試都會 new 一次測試類別，所以每個測試拿到的都是全新的替身\n",[376,24350,24351,24353,24356],{"class":378,"line":542},[376,24352,12192],{"class":3196},[376,24354,24355],{"class":1020}," CreateOrderServiceTest",[376,24357,3165],{"class":393},[376,24359,24360],{"class":378,"line":552},[376,24361,5704],{"class":393},[376,24363,24364,24367,24369,24371,24373],{"class":378,"line":564},[376,24365,24366],{"class":393},"        _timeProvider ",[376,24368,3240],{"class":3196},[376,24370,4692],{"class":3196},[376,24372,23280],{"class":1020},[376,24374,3532],{"class":393},[376,24376,24377,24380,24382,24384,24386,24388,24391],{"class":378,"line":575},[376,24378,24379],{"class":393},"        _generatorIdOutPort ",[376,24381,3240],{"class":3196},[376,24383,22780],{"class":393},[376,24385,22783],{"class":1020},[376,24387,3176],{"class":393},[376,24389,24390],{"class":1020},"IGeneratorIdOutPort",[376,24392,18601],{"class":393},[376,24394,24395,24398,24400,24402,24404,24406,24409],{"class":378,"line":583},[376,24396,24397],{"class":393},"        _orderOutPort ",[376,24399,3240],{"class":3196},[376,24401,22780],{"class":393},[376,24403,22783],{"class":1020},[376,24405,3176],{"class":393},[376,24407,24408],{"class":1020},"IOrderOutPort",[376,24410,18601],{"class":393},[376,24412,24413,24416,24418,24420,24422,24424,24427],{"class":378,"line":597},[376,24414,24415],{"class":393},"        _memberOutPort ",[376,24417,3240],{"class":3196},[376,24419,22780],{"class":393},[376,24421,22783],{"class":1020},[376,24423,3176],{"class":393},[376,24425,24426],{"class":1020},"IMemberOutPort",[376,24428,18601],{"class":393},[376,24430,24431,24434,24436,24438,24440,24442,24445],{"class":378,"line":605},[376,24432,24433],{"class":393},"        _domainEventBus ",[376,24435,3240],{"class":3196},[376,24437,22780],{"class":393},[376,24439,22783],{"class":1020},[376,24441,3176],{"class":393},[376,24443,24444],{"class":1020},"IDomainEventBus",[376,24446,18601],{"class":393},[376,24448,24449],{"class":378,"line":616},[376,24450,5766],{"class":393},[376,24452,24453],{"class":378,"line":624},[376,24454,1036],{"emptyLinePlaceholder":1035},[376,24456,24457,24459,24462,24464],{"class":378,"line":635},[376,24458,20728],{"class":3196},[376,24460,24461],{"class":1020}," ICreateOrderService",[376,24463,23737],{"class":1020},[376,24465,3165],{"class":393},[376,24467,24468],{"class":378,"line":5815},[376,24469,5704],{"class":393},[376,24471,24472,24474,24476,24479],{"class":378,"line":7233},[376,24473,3485],{"class":3196},[376,24475,4692],{"class":3196},[376,24477,24478],{"class":1020}," CreateOrderService",[376,24480,3294],{"class":393},[376,24482,24483],{"class":378,"line":7243},[376,24484,24485],{"class":393},"            _generatorIdOutPort, _timeProvider, _orderOutPort, _domainEventBus, _memberOutPort);\n",[376,24487,24488],{"class":378,"line":7251},[376,24489,5766],{"class":393},[376,24491,24492],{"class":378,"line":7721},[376,24493,3381],{"class":393},[66,24495,24496,24502,24514],{},[69,24497,24498,24501],{},[23,24499,24500],{},"建構子","：只負責「建立」替身，不設定行為。",[69,24503,24504,24507,24508,2390,24511,5226],{},[23,24505,24506],{},"每個測試的 Arrange","：才設定這個情境需要的回傳值（",[15,24509,24510],{},"Returns(true)",[15,24512,24513],{},"Returns(false)",[69,24515,24516,24521],{},[23,24517,24518],{},[15,24519,24520],{},"GetSystemUnderTest()","：集中建立被測物件。之後建構子多了一個參數，只需要改這一個地方，不用每個測試都改。",[11,24523,24524,24525,24528],{},"「情境差異」留在每個測試裡，讀測試時才看得出這個案例跟其他案例差在哪；如果把 ",[15,24526,24527],{},"Returns(...)"," 也搬進建構子，每個測試看起來都一樣，差異反而被藏起來了。",[100,24530,24532],{"id":24531},"_3a-的幾條守則","3A 的幾條守則",[211,24534,24535,24541,24547,24553],{},[69,24536,24537,24540],{},[23,24538,24539],{},"Act 只有一個動作。"," 如果 Act 裡呼叫了兩個方法，通常代表這其實是兩個測試。",[69,24542,24543,24546],{},[23,24544,24545],{},"Assert 驗證同一件事。"," 可以有多行 Assert（例如同時檢查回傳值與事件），但它們應該都在描述「同一個行為的結果」。",[69,24548,24549,24552],{},[23,24550,24551],{},"三段不交錯。"," 不要 Arrange → Act → Assert → 再 Act → 再 Assert，那是一個流程測試，不是單元測試。",[69,24554,24555,24558,24559,24562,24563,24566,24567,24570],{},[23,24556,24557],{},"變數命名表達角色。"," 被測物件叫 ",[15,24560,24561],{},"sut","（System Under Test），結果叫 ",[15,24564,24565],{},"actual","，預期值叫 ",[15,24568,24569],{},"expected","——讀的人一看就知道哪個是哪個。",[28,24572,24574],{"id":24573},"測試方法的命名方法_情境_預期結果","測試方法的命名：方法_情境_預期結果",[11,24576,24577],{},"我採用的命名格式是：",[36,24579,24582],{"className":24580,"code":24581,"language":41},[39],"被測方法_情境（輸入或前置狀態）_預期結果\n",[15,24583,24581],{"__ignoreMap":44},[11,24585,24586,24587,24590],{},"這也是 Roy Osherove 在《The Art of Unit Testing》提出的 ",[15,24588,24589],{},"UnitOfWork_StateUnderTest_ExpectedBehavior"," 格式。套到專案裡的實際例子：",[132,24592,24593,24609],{},[135,24594,24595],{},[138,24596,24597,24600,24603,24606],{},[141,24598,24599],{"align":143},"測試方法名稱",[141,24601,24602],{"align":143},"被測方法",[141,24604,24605],{"align":143},"情境",[141,24607,24608],{"align":143},"預期結果",[152,24610,24611,24629,24646,24664,24681],{},[138,24612,24613,24618,24623,24626],{},[157,24614,24615],{"align":143},[15,24616,24617],{},"OrderCreate_輸入符合預期的參數_傳回訂單",[157,24619,24620],{"align":143},[15,24621,24622],{},"Order.New",[157,24624,24625],{"align":143},"參數正確",[157,24627,24628],{"align":143},"回傳訂單",[138,24630,24631,24636,24640,24643],{},[157,24632,24633],{"align":143},[15,24634,24635],{},"OrderCreate_輸入訂單項目為空_應拋出OrderDomainException",[157,24637,24638],{"align":143},[15,24639,24622],{},[157,24641,24642],{"align":143},"沒有訂單項目",[157,24644,24645],{"align":143},"拋出領域例外",[138,24647,24648,24653,24658,24661],{},[157,24649,24650],{"align":143},[15,24651,24652],{},"ConfirmPayment_訂單已付款_應更新訂單狀態為已付款",[157,24654,24655],{"align":143},[15,24656,24657],{},"ConfirmPayment",[157,24659,24660],{"align":143},"付款確認",[157,24662,24663],{"align":143},"狀態變為已付款",[138,24665,24666,24671,24675,24678],{},[157,24667,24668],{"align":143},[15,24669,24670],{},"HandleAsyncTest_輸入的會員Id不存在_拋出MemberNotFoundException",[157,24672,24673],{"align":143},[15,24674,19138],{},[157,24676,24677],{"align":143},"會員不存在",[157,24679,24680],{"align":143},"拋出例外",[138,24682,24683,24688,24692,24695],{},[157,24684,24685],{"align":143},[15,24686,24687],{},"HandleAsyncTest_儲存訂單失敗_應回傳False",[157,24689,24690],{"align":143},[15,24691,19138],{},[157,24693,24694],{"align":143},"存檔失敗",[157,24696,24697],{"align":143},"回傳 False",[11,24699,24700,24701,24704,24705,24707],{},"把同一個類別的測試名稱列在一起，其實就是",[23,24702,24703],{},"這個類別的規格書","。在 Test Explorer 裡看到紅燈，例如 ",[15,24706,24687],{}," 失敗，不用打開程式碼就知道：存檔失敗時的處理邏輯被改壞了。",[100,24709,24711],{"id":24710},"為什麼用中文命名","為什麼用中文命名？",[11,24713,24714],{},"C# 的識別字支援 Unicode，測試方法用中文命名完全合法。我在情境與預期結果使用中文，理由是：",[66,24716,24717,24723,24729],{},[69,24718,24719,24722],{},[23,24720,24721],{},"業務規則本來就是用中文討論的。"," 「會員不存在」「訂單項目為空」直接對應需求文件與跟 PM 的對話，不需要在腦中翻譯一次。",[69,24724,24725,24728],{},[23,24726,24727],{},"測試方法不會被其他程式碼呼叫。"," 它不是 API，不用顧慮呼叫端的打字體驗。",[69,24730,24731,24734],{},[23,24732,24733],{},"被測方法名稱保留英文。"," 第一段是程式碼裡的方法名稱，保持原樣，方便搜尋與對照。",[11,24736,24737,24738,24741,24742,24745],{},"如果團隊有非中文使用者，或 CI 環境對 Unicode 支援不佳，改用英文也完全沒問題，例如 ",[15,24739,24740],{},"HandleAsyncTest_MemberNotFound_ThrowsMemberNotFoundException","——重點是",[23,24743,24744],{},"格式一致","，語言是團隊的選擇。",[100,24747,24748],{"id":24748},"命名的幾個細節",[11,24750,24751,14372,24754,2276,24757,24760,24761,2276,24764,24767],{},[23,24752,24753],{},"1. 預期結果要具體。",[15,24755,24756],{},"傳回True",[15,24758,24759],{},"拋出MemberNotFoundException"," 比 ",[15,24762,24763],{},"成功",[15,24765,24766],{},"失敗"," 好。「失敗」可能是回傳 false、拋例外、還是回傳 null？名稱要能回答這個問題。",[11,24769,24770,14372,24773,24776,24777,24780],{},[23,24771,24772],{},"2. 情境描述「條件」，不是描述「測試步驟」。",[15,24774,24775],{},"輸入的會員Id不存在"," 是條件；",[15,24778,24779],{},"呼叫IsExistAsync回傳false後再呼叫HandleAsync"," 是步驟，後者把實作細節寫進名稱，實作一改名稱就過時。",[11,24782,24783,14372,24786,24789,24790,1540,24793,24789,24795,24798,24799,24802,24803,24806],{},[23,24784,24785],{},"3. 類別名稱對應被測類別。",[15,24787,24788],{},"CreateOrderService"," 的測試放在 ",[15,24791,24792],{},"CreateOrderServiceTest",[15,24794,23113],{},[15,24796,24797],{},"OrderTest","。專案結構也對稱：",[15,24800,24801],{},"OrderService.UseCase"," 對應 ",[15,24804,24805],{},"OrderService.UseCase.Tests","。找測試的時候不用猜。",[100,24808,24809],{"id":24809},"其他語言的命名慣例",[11,24811,24812],{},"不同語言的測試框架有各自的限制與慣例，但「方法、情境、預期」三個要素是共通的：",[11,24814,24815,24818,24819,24822,24823,24826],{},[23,24816,24817],{},"Go","：測試函式必須以 ",[15,24820,24821],{},"Test"," 開頭，常見寫法是 ",[15,24824,24825],{},"Test方法_情境","，並在註解補充說明：",[36,24828,24832],{"className":24829,"code":24830,"language":24831,"meta":44,"style":44},"language-go shiki shiki-themes github-dark","\u002F\u002F TestCreateRedis_Success 測試成功建立 Redis 申請\nfunc TestCreateRedis_Success(t *testing.T) { ... }\n\nfunc TestCreateProject_ProjectNameTooLong(t *testing.T) { ... }\n","go",[15,24833,24834,24839,24871,24875],{"__ignoreMap":44},[376,24835,24836],{"class":378,"line":379},[376,24837,24838],{"class":382},"\u002F\u002F TestCreateRedis_Success 測試成功建立 Redis 申請\n",[376,24840,24841,24844,24847,24849,24852,24855,24858,24860,24863,24866,24869],{"class":378,"line":386},[376,24842,24843],{"class":3196},"func",[376,24845,24846],{"class":1020}," TestCreateRedis_Success",[376,24848,3220],{"class":393},[376,24850,18651],{"class":24851},"s9osk",[376,24853,24854],{"class":3196}," *",[376,24856,24857],{"class":1020},"testing",[376,24859,4735],{"class":393},[376,24861,24862],{"class":1020},"T",[376,24864,24865],{"class":393},") { ",[376,24867,24868],{"class":3196},"...",[376,24870,3610],{"class":393},[376,24872,24873],{"class":378,"line":397},[376,24874,1036],{"emptyLinePlaceholder":1035},[376,24876,24877,24879,24882,24884,24886,24888,24890,24892,24894,24896,24898],{"class":378,"line":405},[376,24878,24843],{"class":3196},[376,24880,24881],{"class":1020}," TestCreateProject_ProjectNameTooLong",[376,24883,3220],{"class":393},[376,24885,18651],{"class":24851},[376,24887,24854],{"class":3196},[376,24889,24857],{"class":1020},[376,24891,4735],{"class":393},[376,24893,24862],{"class":1020},[376,24895,24865],{"class":393},[376,24897,24868],{"class":3196},[376,24899,3610],{"class":393},[11,24901,24902,24903,24905,24906,24909],{},"情境比較多時，Go 社群更常用 table-driven test，把情境寫在 ",[15,24904,17268],{}," 欄位，用 ",[15,24907,24908],{},"t.Run"," 跑子測試：",[36,24911,24913],{"className":24829,"code":24912,"language":24831,"meta":44,"style":44},"tests := []struct {\n    name    string\n    input   string\n    wantErr bool\n}{\n    {name: \"專案名稱超過長度上限_回傳錯誤\", input: strings.Repeat(\"a\", maxNameLength+1), wantErr: true},\n    {name: \"專案名稱正常_不回傳錯誤\", input: \"order\", wantErr: false},\n}\nfor _, tt := range tests {\n    t.Run(tt.name, func(t *testing.T) { ... })\n}\n",[15,24914,24915,24931,24939,24946,24954,24959,24993,25013,25017,25033,25064],{"__ignoreMap":44},[376,24916,24917,24920,24923,24926,24929],{"class":378,"line":379},[376,24918,24919],{"class":393},"tests ",[376,24921,24922],{"class":3196},":=",[376,24924,24925],{"class":393}," []",[376,24927,24928],{"class":3196},"struct",[376,24930,15632],{"class":393},[376,24932,24933,24936],{"class":378,"line":386},[376,24934,24935],{"class":393},"    name    ",[376,24937,24938],{"class":3196},"string\n",[376,24940,24941,24944],{"class":378,"line":397},[376,24942,24943],{"class":393},"    input   ",[376,24945,24938],{"class":3196},[376,24947,24948,24951],{"class":378,"line":405},[376,24949,24950],{"class":393},"    wantErr ",[376,24952,24953],{"class":3196},"bool\n",[376,24955,24956],{"class":378,"line":418},[376,24957,24958],{"class":393},"}{\n",[376,24960,24961,24964,24967,24970,24973,24975,24978,24981,24983,24985,24988,24990],{"class":378,"line":429},[376,24962,24963],{"class":393},"    {name: ",[376,24965,24966],{"class":414},"\"專案名稱超過長度上限_回傳錯誤\"",[376,24968,24969],{"class":393},", input: strings.",[376,24971,24972],{"class":1020},"Repeat",[376,24974,3220],{"class":393},[376,24976,24977],{"class":414},"\"a\"",[376,24979,24980],{"class":393},", maxNameLength",[376,24982,13474],{"class":3196},[376,24984,892],{"class":560},[376,24986,24987],{"class":393},"), wantErr: ",[376,24989,18747],{"class":560},[376,24991,24992],{"class":393},"},\n",[376,24994,24995,24997,25000,25003,25006,25009,25011],{"class":378,"line":437},[376,24996,24963],{"class":393},[376,24998,24999],{"class":414},"\"專案名稱正常_不回傳錯誤\"",[376,25001,25002],{"class":393},", input: ",[376,25004,25005],{"class":414},"\"order\"",[376,25007,25008],{"class":393},", wantErr: ",[376,25010,5575],{"class":560},[376,25012,24992],{"class":393},[376,25014,25015],{"class":378,"line":447},[376,25016,3381],{"class":393},[376,25018,25019,25022,25025,25027,25030],{"class":378,"line":531},[376,25020,25021],{"class":3196},"for",[376,25023,25024],{"class":393}," _, tt ",[376,25026,24922],{"class":3196},[376,25028,25029],{"class":3196}," range",[376,25031,25032],{"class":393}," tests {\n",[376,25034,25035,25038,25040,25043,25045,25047,25049,25051,25053,25055,25057,25059,25061],{"class":378,"line":542},[376,25036,25037],{"class":393},"    t.",[376,25039,5671],{"class":1020},[376,25041,25042],{"class":393},"(tt.name, ",[376,25044,24843],{"class":3196},[376,25046,3220],{"class":393},[376,25048,18651],{"class":24851},[376,25050,24854],{"class":3196},[376,25052,24857],{"class":1020},[376,25054,4735],{"class":393},[376,25056,24862],{"class":1020},[376,25058,24865],{"class":393},[376,25060,24868],{"class":3196},[376,25062,25063],{"class":393}," })\n",[376,25065,25066],{"class":378,"line":552},[376,25067,3381],{"class":393},[11,25069,25070,25073],{},[23,25071,25072],{},"JavaScript \u002F TypeScript（Jest、Vitest）","：用字串描述，天生適合寫成句子：",[36,25075,25079],{"className":25076,"code":25077,"language":25078,"meta":44,"style":44},"language-ts shiki shiki-themes github-dark","describe('CreateOrderService.handle', () => {\n  it('會員不存在時，應拋出 MemberNotFoundError', async () => { ... })\n})\n","ts",[15,25080,25081,25098,25122],{"__ignoreMap":44},[376,25082,25083,25086,25088,25091,25094,25096],{"class":378,"line":379},[376,25084,25085],{"class":1020},"describe",[376,25087,3220],{"class":393},[376,25089,25090],{"class":414},"'CreateOrderService.handle'",[376,25092,25093],{"class":393},", () ",[376,25095,5665],{"class":3196},[376,25097,15632],{"class":393},[376,25099,25100,25103,25105,25108,25110,25112,25114,25116,25118,25120],{"class":378,"line":386},[376,25101,25102],{"class":1020},"  it",[376,25104,3220],{"class":393},[376,25106,25107],{"class":414},"'會員不存在時，應拋出 MemberNotFoundError'",[376,25109,3188],{"class":393},[376,25111,9452],{"class":3196},[376,25113,9455],{"class":393},[376,25115,5665],{"class":3196},[376,25117,9621],{"class":393},[376,25119,24868],{"class":3196},[376,25121,25063],{"class":393},[376,25123,25124],{"class":378,"line":397},[376,25125,25126],{"class":393},"})\n",[28,25128,25129],{"id":25129},"小結",[66,25131,25132,25138,25144,25150,25156],{},[69,25133,25134,25137],{},[23,25135,25136],{},"3A 給測試一個固定的閱讀順序","：先看 Act 知道測哪個方法，再看 Assert 知道預期什麼，最後才需要看 Arrange 的細節。",[69,25139,25140,25143],{},[23,25141,25142],{},"Act 只做一件事","，例外要包成委派再交給 Assert 驗證。",[69,25145,25146,25149],{},[23,25147,25148],{},"Assert 同時考慮狀態與行為","：回傳值對了，該發的事件有沒有發、不該發的有沒有擋住。",[69,25151,25152,25155],{},[23,25153,25154],{},"命名用「方法_情境_預期結果」","，整個測試類別的方法名稱列出來就是一份規格書。",[69,25157,25158,25161],{},[23,25159,25160],{},"名稱要具體、描述條件而非步驟","，讓紅燈本身就能說明是哪條業務規則壞了。",[11,25163,25164],{},"測試程式碼也是程式碼，而且是最常被「讀」的那一種：每次紅燈、每次重構、每次新人接手，都會有人回來讀它。花一點力氣讓它好讀，回報會比想像中大。",[1132,25166,25167],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":44,"searchDepth":386,"depth":386,"links":25169},[25170,25177,25182],{"id":23034,"depth":386,"text":23035,"children":25171},[25172,25173,25174,25175,25176],{"id":23106,"depth":397,"text":23107},{"id":23460,"depth":397,"text":23461},{"id":24048,"depth":397,"text":24049},{"id":24248,"depth":397,"text":24249},{"id":24531,"depth":397,"text":24532},{"id":24573,"depth":386,"text":24574,"children":25178},[25179,25180,25181],{"id":24710,"depth":397,"text":24711},{"id":24748,"depth":397,"text":24748},{"id":24809,"depth":397,"text":24809},{"id":25129,"depth":386,"text":25129},"2023-12-05","寫單元測試時，最常遇到的問題通常不是「不會寫」，而是三個月後回來看，看不懂當初在測什麼。測試失敗時，紅燈只告訴你一個方法名稱；如果名稱是 Test1、CreateOrderTest2，還得把整段測試讀完、甚至 debug，才知道是哪個業務規則壞了。",{},"\u002Farticles\u002Funit-test-aaa-naming",{"title":22995,"description":25184},"articles\u002Funit-test-aaa-naming",[25190,25191,19839,25192,1233],"Unit Test","TDD","xUnit","sBCGX5Jqr4Wqlw9D61SDDMc9AYCQ7kWkPPY7XDt2DjI",{"id":25195,"title":25196,"body":25197,"date":25668,"description":44,"excerpt":1156,"extension":1157,"meta":25669,"navigation":1035,"path":16687,"seo":25670,"stem":25671,"tags":25672,"__hash__":25674},"articles\u002Farticles\u002Fcqrs-elasticsearch-read-model.md","CQRS 實戰：把讀取交給 Elasticsearch，寫入留給關聯式資料庫",{"type":8,"value":25198,"toc":25659},[25199,25203,25206,25272,25279,25283,25293,25299,25331,25340,25344,25350,25357,25533,25536,25543,25550,25554,25564,25567,25593,25597,25600,25620,25627,25631,25634,25648,25651,25653,25656],[28,25200,25202],{"id":25201},"讀和寫其實是兩種完全不同的需求","讀和寫，其實是兩種完全不同的需求",[11,25204,25205],{},"以房仲網站的「物件」為例：",[132,25207,25208,25220],{},[135,25209,25210],{},[138,25211,25212,25214,25217],{},[141,25213],{},[141,25215,25216],{},"寫入（Command）",[141,25218,25219],{},"讀取（Query）",[152,25221,25222,25233,25244,25261],{},[138,25223,25224,25227,25230],{},[157,25225,25226],{},"誰在做",[157,25228,25229],{},"房仲經紀人在後台刊登、改價、下架",[157,25231,25232],{},"一般使用者搜尋、瀏覽、看地圖",[138,25234,25235,25238,25241],{},[157,25236,25237],{},"量",[157,25239,25240],{},"少",[157,25242,25243],{},"多出好幾個數量級",[138,25245,25246,25249,25255],{},[157,25247,25248],{},"在乎什麼",[157,25250,25251,25254],{},[23,25252,25253],{},"正確","：權限、商業規則、資料完整性",[157,25256,25257,25260],{},[23,25258,25259],{},"快","：多條件篩選、全文搜尋、地理範圍",[138,25262,25263,25266,25269],{},[157,25264,25265],{},"資料的形狀",[157,25267,25268],{},"正規化：物件、社區、店鋪、照片分表存放",[157,25270,25271],{},"反正規化：一次拿到列表要顯示的所有欄位",[11,25273,25274,25275,25278],{},"當讀寫共用同一個資料庫、同一組資料表時，兩邊會互相拖累：為了讓搜尋變快加了一堆索引，寫入就變慢；一個列表查詢要 JOIN 五張表，再疊上 ",[15,25276,25277],{},"LIKE '%關鍵字%'"," 和範圍條件，資料量一大就要好幾秒；為了撐住查詢加了應用程式快取，資料更新後快取又不知道什麼時候才會過期。",[28,25280,25282],{"id":25281},"cqrs讀寫分開各自最佳化","CQRS：讀寫分開，各自最佳化",[11,25284,25285,25288,25289,25292],{},[23,25286,25287],{},"CQRS（Command Query Responsibility Segregation，命令查詢職責分離）"," 的想法很簡單：既然讀寫的需求不同，就",[23,25290,25291],{},"用兩個不同的模型","來處理。",[11,25294,25295],{},[1952,25296],{"alt":25297,"src":25298},"CQRS 架構示意圖：寫入端由 Command Handler 驗證商業規則後寫入 MSSQL 正規化的寫入模型並產生事件；Projector 非同步接收事件，組出反正規化的文件寫入 Elasticsearch 讀取模型；讀取端由 Query Handler 查詢 Elasticsearch 回傳搜尋、列表與地圖資料","\u002Fimages\u002Farticles\u002Fcqrs-read-model.svg",[66,25300,25301,25311,25321],{},[69,25302,25303,25306,25307,25310],{},[23,25304,25305],{},"寫入端（Command Side）","：處理「改變狀態」的請求，負責驗證商業規則，資料存放在關聯式資料庫，維持正規化與交易一致性。Command ",[23,25308,25309],{},"不回傳查詢資料","，頂多回傳成功與否、新建立的 ID",[69,25312,25313,25316,25317,25320],{},[23,25314,25315],{},"讀取端（Query Side）","：處理「查資料」的請求，",[23,25318,25319],{},"不改變任何狀態","。資料存放在為查詢最佳化的儲存體——在房仲網站的情境裡就是 Elasticsearch",[69,25322,25323,25326,25327,25330],{},[23,25324,25325],{},"同步機制","：寫入端完成變更後發出事件，由 ",[23,25328,25329],{},"Projector"," 接收事件，把資料轉換成讀取模型需要的形狀，寫進 Elasticsearch",[7381,25332,25333],{},[11,25334,25335,25336,25339],{},"CQRS 常和 ",[23,25337,25338],{},"Event Sourcing"," 一起被提到，但兩者是獨立的概念。Event Sourcing 是「寫入端不存目前的狀態，而是存一連串的事件，狀態由事件重播得出」。CQRS 不一定要搭配 Event Sourcing，寫入端用一般的資料表存目前狀態，再另外發出事件同步給讀取端，就已經是 CQRS。",[28,25341,25343],{"id":25342},"讀取模型怎麼設計為畫面而生","讀取模型怎麼設計：為畫面而生",[11,25345,25346,25347,114],{},"讀取模型的設計原則和資料庫正規化剛好相反：",[23,25348,25349],{},"畫面需要什麼，文件就長什麼樣子",[11,25351,25352,25353,25356],{},"物件列表上的一張卡片要顯示：物件標題、總價、坪數、房數、社區名稱、店鋪名稱、主照片、經緯度。在寫入端，這些資料分散在物件、社區、店鋪、照片四張表；在讀取端，它們被",[23,25354,25355],{},"預先組好","放進同一份文件：",[36,25358,25362],{"className":25359,"code":25360,"language":25361,"meta":44,"style":44},"language-json shiki shiki-themes github-dark","{\n  \"id\": \"H-102938\",\n  \"title\": \"信義區三房車位 近捷運\",\n  \"price\": 2380,\n  \"area\": 34.5,\n  \"rooms\": 3,\n  \"community\": { \"id\": \"C-551\", \"name\": \"信義 A 社區\" },\n  \"store\": { \"id\": \"S-077\", \"name\": \"信義松仁店\" },\n  \"coverImage\": \"https:\u002F\u002Fimg.example.com\u002FH-102938\u002F1.jpg\",\n  \"location\": { \"lat\": 25.0330, \"lon\": 121.5654 },\n  \"updatedAt\": \"2023-03-20T08:15:00Z\"\n}\n","json",[15,25363,25364,25368,25380,25392,25404,25416,25427,25455,25480,25492,25519,25529],{"__ignoreMap":44},[376,25365,25366],{"class":378,"line":379},[376,25367,3324],{"class":393},[376,25369,25370,25373,25375,25378],{"class":378,"line":386},[376,25371,25372],{"class":560},"  \"id\"",[376,25374,411],{"class":393},[376,25376,25377],{"class":414},"\"H-102938\"",[376,25379,3226],{"class":393},[376,25381,25382,25385,25387,25390],{"class":378,"line":397},[376,25383,25384],{"class":560},"  \"title\"",[376,25386,411],{"class":393},[376,25388,25389],{"class":414},"\"信義區三房車位 近捷運\"",[376,25391,3226],{"class":393},[376,25393,25394,25397,25399,25402],{"class":378,"line":405},[376,25395,25396],{"class":560},"  \"price\"",[376,25398,411],{"class":393},[376,25400,25401],{"class":560},"2380",[376,25403,3226],{"class":393},[376,25405,25406,25409,25411,25414],{"class":378,"line":418},[376,25407,25408],{"class":560},"  \"area\"",[376,25410,411],{"class":393},[376,25412,25413],{"class":560},"34.5",[376,25415,3226],{"class":393},[376,25417,25418,25421,25423,25425],{"class":378,"line":429},[376,25419,25420],{"class":560},"  \"rooms\"",[376,25422,411],{"class":393},[376,25424,6235],{"class":560},[376,25426,3226],{"class":393},[376,25428,25429,25432,25434,25437,25439,25442,25444,25447,25449,25452],{"class":378,"line":437},[376,25430,25431],{"class":560},"  \"community\"",[376,25433,3589],{"class":393},[376,25435,25436],{"class":560},"\"id\"",[376,25438,411],{"class":393},[376,25440,25441],{"class":414},"\"C-551\"",[376,25443,3188],{"class":393},[376,25445,25446],{"class":560},"\"name\"",[376,25448,411],{"class":393},[376,25450,25451],{"class":414},"\"信義 A 社區\"",[376,25453,25454],{"class":393}," },\n",[376,25456,25457,25460,25462,25464,25466,25469,25471,25473,25475,25478],{"class":378,"line":447},[376,25458,25459],{"class":560},"  \"store\"",[376,25461,3589],{"class":393},[376,25463,25436],{"class":560},[376,25465,411],{"class":393},[376,25467,25468],{"class":414},"\"S-077\"",[376,25470,3188],{"class":393},[376,25472,25446],{"class":560},[376,25474,411],{"class":393},[376,25476,25477],{"class":414},"\"信義松仁店\"",[376,25479,25454],{"class":393},[376,25481,25482,25485,25487,25490],{"class":378,"line":531},[376,25483,25484],{"class":560},"  \"coverImage\"",[376,25486,411],{"class":393},[376,25488,25489],{"class":414},"\"https:\u002F\u002Fimg.example.com\u002FH-102938\u002F1.jpg\"",[376,25491,3226],{"class":393},[376,25493,25494,25497,25499,25502,25504,25507,25509,25512,25514,25517],{"class":378,"line":542},[376,25495,25496],{"class":560},"  \"location\"",[376,25498,3589],{"class":393},[376,25500,25501],{"class":560},"\"lat\"",[376,25503,411],{"class":393},[376,25505,25506],{"class":560},"25.0330",[376,25508,3188],{"class":393},[376,25510,25511],{"class":560},"\"lon\"",[376,25513,411],{"class":393},[376,25515,25516],{"class":560},"121.5654",[376,25518,25454],{"class":393},[376,25520,25521,25524,25526],{"class":378,"line":552},[376,25522,25523],{"class":560},"  \"updatedAt\"",[376,25525,411],{"class":393},[376,25527,25528],{"class":414},"\"2023-03-20T08:15:00Z\"\n",[376,25530,25531],{"class":378,"line":564},[376,25532,3381],{"class":393},[11,25534,25535],{},"查詢時不需要任何 JOIN，一次 Elasticsearch 查詢就能拿到列表的所有資料，還能同時做全文搜尋、多條件篩選與地理範圍查詢。",[11,25537,25538,25539,25542],{},"代價是",[23,25540,25541],{},"資料重複","：社區名稱同時存在於每一筆物件文件裡。社區改名時，Projector 要找出這個社區底下的所有物件，一起更新。這是讀取模型用「寫入時多做事」換取「讀取時少做事」的典型取捨——而在讀多寫少的系統裡，這筆交易非常划算。",[11,25544,25545,25546,25549],{},"同一份寫入資料，也可以",[23,25547,25548],{},"投影出好幾種讀取模型","：物件搜尋用的索引、社區行情彙整用的索引、地圖用的精簡索引，各自為不同的查詢最佳化。",[28,25551,25553],{"id":25552},"最終一致性寫完不一定馬上讀得到","最終一致性：寫完不一定馬上讀得到",[11,25555,25556,25557,25560,25561,114],{},"讀寫分開之後，最大的改變是：",[23,25558,25559],{},"寫入成功的那一刻，讀取端還沒更新","。事件從發出、經過訊息佇列、到 Projector 寫進 Elasticsearch，中間通常是毫秒到秒級的延遲，這就是",[23,25562,25563],{},"最終一致性（Eventual Consistency）",[11,25565,25566],{},"大部分情況下使用者感覺不到這個延遲，但有幾個地方要特別處理：",[66,25568,25569,25575,25581,25587],{},[69,25570,25571,25574],{},[23,25572,25573],{},"經紀人改完價格，馬上回到列表","：看到的還是舊價格，會以為沒改成功。常見做法是寫入成功後，前端直接用送出的資料更新畫面，而不是重新查詢；或是後台的「我的物件」頁面直接查寫入端，不走讀取模型",[69,25576,25577,25580],{},[23,25578,25579],{},"需要強一致的檢查不能查讀取端","：例如「這個經紀人還有沒有剩餘刊登額度」，一定要在寫入端、用交易內的資料判斷，不能查 Elasticsearch 的結果",[69,25582,25583,25586],{},[23,25584,25585],{},"Projector 要冪等","：同一個事件可能被投遞兩次，以物件 ID 為文件 ID 做 Upsert，處理幾次結果都一樣",[69,25588,25589,25592],{},[23,25590,25591],{},"事件的順序","：同一個物件的「改價」和「下架」如果順序顛倒，讀取端可能出現已下架的物件又被寫回去。可以在文件中保存版本號或更新時間，Projector 只接受比目前更新的事件",[28,25594,25596],{"id":25595},"什麼時候不該用-cqrs","什麼時候不該用 CQRS",[11,25598,25599],{},"CQRS 會讓系統多出一套儲存體、一條同步管線、一種一致性問題，並不是每個系統都需要：",[66,25601,25602,25608,25614],{},[69,25603,25604,25607],{},[23,25605,25606],{},"讀寫比例差不多、查詢也不複雜","：一般的後台 CRUD 系統，用同一個資料庫加上適當的索引就夠了",[69,25609,25610,25613],{},[23,25611,25612],{},"團隊還沒有處理非同步、最終一致性的經驗","：同步管線出問題時，讀取端的資料會默默地和寫入端不一致，需要監控與重建的機制",[69,25615,25616,25619],{},[23,25617,25618],{},"業務要求「寫完立刻讀到」的地方很多","：每一個地方都要特別處理，複雜度會快速上升",[11,25621,25622,25623,25626],{},"比較務實的做法是",[23,25624,25625],{},"只在真正需要的地方用","：房仲網站的物件搜尋、列表、地圖是典型的讀多寫少、查詢複雜，非常適合；但會員資料、後台設定這種單純的 CRUD，就留在原本的資料庫即可。",[28,25628,25630],{"id":25629},"使用情境房仲平台的物件搜尋","使用情境：房仲平台的物件搜尋",[11,25632,25633],{},"在房仲平台的重構中，物件、成交行情、社區三大頻道的查詢原本都直接打 MSSQL，再靠每個站台約 10G 的記憶體快取撐住流量。導入 CQRS 之後：",[66,25635,25636,25639,25642],{},[69,25637,25638],{},"物件的刊登、改價、下架走寫入端，資料寫入 MSSQL 後，由同步排程偵測異動並發出事件",[69,25640,25641],{},"事件經由 RabbitMQ 送到 Projector，組成反正規化的文件寫進 Elasticsearch",[69,25643,25644,25645],{},"前台的搜尋、列表、地圖全部改查 Elasticsearch，",[23,25646,25647],{},"不再需要應用程式內的記憶體快取",[11,25649,25650],{},"複雜條件的查詢從 20 秒降到 1.5 秒，移除記憶體快取後，每個站台的記憶體用量也從 10G 降到 800MB 左右。",[28,25652,1890],{"id":1890},[11,25654,25655],{},"CQRS 把「改變狀態」和「查詢資料」拆成兩個模型：寫入端用關聯式資料庫守住正確性，讀取端用為畫面量身打造的反正規化文件換取速度，兩者之間靠事件非同步同步；代價是最終一致性與更多的維運成本，所以只用在讀多寫少、查詢複雜、真正需要它的地方。",[1132,25657,25658],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":44,"searchDepth":386,"depth":386,"links":25660},[25661,25662,25663,25664,25665,25666,25667],{"id":25201,"depth":386,"text":25202},{"id":25281,"depth":386,"text":25282},{"id":25342,"depth":386,"text":25343},{"id":25552,"depth":386,"text":25553},{"id":25595,"depth":386,"text":25596},{"id":25629,"depth":386,"text":25630},{"id":1890,"depth":386,"text":1890},"2023-11-14",{},{"title":25196,"description":44},"articles\u002Fcqrs-elasticsearch-read-model",[21618,25673,3039,3037,4565],"Elasticsearch","c6stcHJhct1IxbJJnyTkWRksdntS8Ct4qtFSdlIpaRM",{"id":25676,"title":25677,"body":25678,"date":26413,"description":44,"excerpt":1156,"extension":1157,"meta":26414,"navigation":1035,"path":26415,"seo":26416,"stem":26417,"tags":26418,"__hash__":26422},"articles\u002Farticles\u002Fopenid-connect-authorization-code-pkce.md","OpenID Connect 登入流程拆解：Authorization Code + PKCE、id_token 與 access_token 的差別",{"type":8,"value":25679,"toc":26402},[25680,25684,25687,25720,25731,25735,25741,25747,25752,25758,25764,25773,25778,25785,25790,25804,25809,25820,25844,25847,25852,25859,25864,25871,25876,25887,25891,25960,25969,25972,25979,26067,26070,26107,26111,26114,26136,26141,26205,26209,26219,26339,26357,26361,26364,26384,26386,26399],[28,25681,25683],{"id":25682},"oauth-20-和-openid-connect-差在哪","OAuth 2.0 和 OpenID Connect 差在哪",[11,25685,25686],{},"這兩個名詞常被混用，但它們解決的是不同的問題：",[66,25688,25689,25706],{},[69,25690,25691,25694,25695],{},[23,25692,25693],{},"OAuth 2.0"," 是",[23,25696,25697,25698,25705],{},"授權（Authorization）",[23,25699,25700,25701,25704],{},"協定：讓一個應用程式取得「代表使用者存取某個資源」的權限，拿到的是 ",[23,25702,25703],{},"access_token","。它本身","沒有定義「這個使用者是誰」",[69,25707,25708,25711,25712,25715,25716,25719],{},[23,25709,25710],{},"OpenID Connect（OIDC）"," 建立在 OAuth 2.0 之上，補上了",[23,25713,25714],{},"身分驗證（Authentication）","：除了 access_token，還多給一張 ",[23,25717,25718],{},"id_token","，裡面寫著「這次登入的使用者是誰」，並且定義了標準的使用者資訊欄位、探索端點（Discovery）、公鑰端點（JWKS）",[11,25721,25722,25723,25726,25727,25730],{},"簡單說：要知道「",[23,25724,25725],{},"你是誰","」用 OIDC，要取得「",[23,25728,25729],{},"你能做什麼","」用 OAuth 2.0。企業內部系統串接 SSO，需要的通常是兩者都有的 OIDC。",[28,25732,25734],{"id":25733},"完整流程authorization-code-pkce","完整流程：Authorization Code + PKCE",[11,25736,25737,25738,55],{},"瀏覽器上的單頁應用（SPA）目前建議使用的流程是 ",[23,25739,25740],{},"Authorization Code Flow 搭配 PKCE",[11,25742,25743],{},[1952,25744],{"alt":25745,"src":25746},"OpenID Connect Authorization Code 加 PKCE 流程：瀏覽器先產生隨機的 code_verifier 並算出 code_challenge；① 導向 OpenID Provider 的 authorize 端點並帶上 scope=openid、code_challenge 與 state；② 使用者輸入公司帳號密碼或 MFA；③ OpenID Provider 帶著授權碼導回 redirect_uri；④ 瀏覽器以授權碼加 code_verifier 呼叫 token 端點，OpenID Provider 驗證 SHA256(verifier) 等於 challenge；⑤ 回傳 id_token 與 access_token；⑥ 瀏覽器以 Bearer access_token 呼叫後端 API 或建立 SignalR 連線；⑦ 後端啟動時下載 JWKS 公鑰，在本機驗證簽章、iss、aud、exp，並取出 sub 作為使用者唯一 ID","\u002Fimages\u002Farticles\u002Foidc-auth-code-flow.svg",[11,25748,25749],{},[23,25750,25751],{},"① 導向授權端點",[11,25753,25754,25755,55],{},"使用者按下登入，前端把瀏覽器導向 OpenID Provider（公司的帳號中心）的 ",[15,25756,25757],{},"\u002Fauthorize",[36,25759,25762],{"className":25760,"code":25761,"language":41,"meta":44},[39],"https:\u002F\u002Fsso.example.com\u002Fauthorize\n  ?response_type=code\n  &client_id=nexus-gpt\n  &redirect_uri=https:\u002F\u002Fgpt.example.com\u002Fcallback\n  &scope=openid profile email\n  &state=8f2c...           ← 防 CSRF：回來時要比對\n  &code_challenge=E9Mel...  ← PKCE\n  &code_challenge_method=S256\n",[15,25763,25761],{"__ignoreMap":44},[11,25765,25766,1517,25769,25772],{},[15,25767,25768],{},"scope",[15,25770,25771],{},"openid"," 是關鍵，代表這是一次 OIDC 登入，要求回傳 id_token。",[11,25774,25775],{},[23,25776,25777],{},"② 使用者在 OpenID Provider 登入",[11,25779,25780,25781,25784],{},"帳號密碼、MFA 都在",[23,25782,25783],{},"帳號中心的頁面","上輸入，應用程式從頭到尾都碰不到使用者的密碼。這是 SSO 最大的價值：只有一個地方需要保護密碼。",[11,25786,25787],{},[23,25788,25789],{},"③ 帶著授權碼導回",[11,25791,25792,25793,25796,25797,25800,25801,25803],{},"登入成功後，帳號中心把瀏覽器導回 ",[15,25794,25795],{},"redirect_uri","，網址上帶著一次性的**授權碼（code）**和原本的 ",[15,25798,25799],{},"state","。前端要先比對 ",[15,25802,25799],{}," 和自己發出去的是否相同，不同就代表這不是自己發起的登入，直接拒絕。",[11,25805,25806],{},[23,25807,25808],{},"④ 用授權碼換 Token",[11,25810,25811,25812,25815,25816,25819],{},"前端拿授權碼呼叫帳號中心的 ",[15,25813,25814],{},"\u002Ftoken"," 端點換取 Token。授權碼出現在網址上，可能被瀏覽器紀錄、外掛、或惡意的 redirect 攔截；",[23,25817,25818],{},"PKCE"," 就是為了讓「攔截到授權碼的人也換不到 Token」：",[66,25821,25822,25829,25835],{},[69,25823,25824,25825,25828],{},"在步驟 ① 之前，前端先產生一個隨機字串 ",[15,25826,25827],{},"code_verifier","，只存在自己手上",[69,25830,25831,25832],{},"步驟 ① 送出的是它的雜湊值 ",[15,25833,25834],{},"code_challenge = BASE64URL(SHA256(code_verifier))",[69,25836,25837,25838,14372,25841,25843],{},"步驟 ④ 換 Token 時，要附上",[23,25839,25840],{},"原始的",[15,25842,25827],{},"，帳號中心算一次雜湊，和 ① 收到的 challenge 比對",[11,25845,25846],{},"攔截者只拿得到授權碼和雜湊值，拿不到原始的 verifier，所以換不到 Token。",[11,25848,25849],{},[23,25850,25851],{},"⑤ 拿到 id_token 與 access_token",[11,25853,25854,25855,25858],{},"兩者通常都是 ",[23,25856,25857],{},"JWT","（JSON Web Token），由帳號中心用私鑰簽章。",[11,25860,25861],{},[23,25862,25863],{},"⑥ 帶 access_token 呼叫 API",[11,25865,25866,25867,25870],{},"之後前端呼叫後端 API 時，在 ",[15,25868,25869],{},"Authorization: Bearer \u003Caccess_token>"," 標頭帶上 access_token。",[11,25872,25873],{},[23,25874,25875],{},"⑦ 後端驗證 Token",[11,25877,25878,25879,25882,25883,25886],{},"後端不需要每次都問帳號中心「這張 Token 是不是真的」。它在啟動時從帳號中心的 Discovery 端點（",[15,25880,25881],{},"\u002F.well-known\u002Fopenid-configuration","）找到 ",[23,25884,25885],{},"JWKS","（公鑰），之後每個請求都在本機用公鑰驗證簽章即可。",[28,25888,25890],{"id":25889},"id_token-和-access_token用途不同不要混用","id_token 和 access_token：用途不同，不要混用",[132,25892,25893,25903],{},[135,25894,25895],{},[138,25896,25897,25899,25901],{},[141,25898],{},[141,25900,25718],{},[141,25902,25703],{},[152,25904,25905,25920,25930,25947],{},[138,25906,25907,25910,25915],{},[157,25908,25909],{},"給誰看",[157,25911,25912],{},[23,25913,25914],{},"前端應用程式",[157,25916,25917],{},[23,25918,25919],{},"後端 API（資源伺服器）",[138,25921,25922,25924,25927],{},[157,25923,18185],{},[157,25925,25926],{},"證明「誰登入了」，前端拿來顯示使用者名稱、頭像",[157,25928,25929],{},"證明「有權限呼叫這個 API」",[138,25931,25932,25938,25944],{},[157,25933,25934,25937],{},[15,25935,25936],{},"aud","（對象）",[157,25939,25940,25941],{},"前端應用程式的 ",[15,25942,25943],{},"client_id",[157,25945,25946],{},"後端 API 的識別名稱",[138,25948,25949,25952,25957],{},[157,25950,25951],{},"該送到 API 嗎",[157,25953,25954],{},[23,25955,25956],{},"不該",[157,25958,25959],{},"該",[11,25961,25962,25963,25965,25966,25968],{},"最常見的錯誤是把 id_token 送給後端 API 當作通行證。id_token 的 ",[15,25964,25936],{}," 是前端應用程式，後端如果有正確驗證 ",[15,25967,25936],{},"，應該要拒絕它；如果沒驗證，就等於任何應用程式拿到的 id_token 都能拿來呼叫你的 API。",[28,25970,25971],{"id":25971},"後端該驗證什麼",[11,25973,25974,25975,25978],{},"ASP.NET Core 中，設定好 ",[15,25976,25977],{},"Authority"," 後，JWT Bearer 驗證會自動下載公鑰並檢查大部分項目：",[36,25980,25982],{"className":3145,"code":25981,"language":3147,"meta":44,"style":44},"builder.Services\n    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)\n    .AddJwtBearer(options =>\n    {\n        options.Authority = \"https:\u002F\u002Fsso.example.com\";   \u002F\u002F 由此找到 Discovery 與 JWKS\n        options.Audience  = \"nexus-gpt-api\";               \u002F\u002F 只接受發給這個 API 的 Token\n        options.MapInboundClaims = false;                  \u002F\u002F 保留原始的 claim 名稱（sub、email…）\n    });\n",[15,25983,25984,25989,25999,26012,26016,26032,26048,26063],{"__ignoreMap":44},[376,25985,25986],{"class":378,"line":379},[376,25987,25988],{"class":393},"builder.Services\n",[376,25990,25991,25993,25996],{"class":378,"line":386},[376,25992,3170],{"class":393},[376,25994,25995],{"class":1020},"AddAuthentication",[376,25997,25998],{"class":393},"(JwtBearerDefaults.AuthenticationScheme)\n",[376,26000,26001,26003,26006,26008,26010],{"class":378,"line":397},[376,26002,3170],{"class":393},[376,26004,26005],{"class":1020},"AddJwtBearer",[376,26007,3220],{"class":393},[376,26009,17398],{"class":1020},[376,26011,12037],{"class":3196},[376,26013,26014],{"class":378,"line":405},[376,26015,5704],{"class":393},[376,26017,26018,26021,26023,26026,26029],{"class":378,"line":418},[376,26019,26020],{"class":393},"        options.Authority ",[376,26022,3240],{"class":3196},[376,26024,26025],{"class":414}," \"https:\u002F\u002Fsso.example.com\"",[376,26027,26028],{"class":393},";   ",[376,26030,26031],{"class":382},"\u002F\u002F 由此找到 Discovery 與 JWKS\n",[376,26033,26034,26037,26039,26042,26045],{"class":378,"line":429},[376,26035,26036],{"class":393},"        options.Audience  ",[376,26038,3240],{"class":3196},[376,26040,26041],{"class":414}," \"nexus-gpt-api\"",[376,26043,26044],{"class":393},";               ",[376,26046,26047],{"class":382},"\u002F\u002F 只接受發給這個 API 的 Token\n",[376,26049,26050,26053,26055,26057,26060],{"class":378,"line":437},[376,26051,26052],{"class":393},"        options.MapInboundClaims ",[376,26054,3240],{"class":3196},[376,26056,3425],{"class":560},[376,26058,26059],{"class":393},";                  ",[376,26061,26062],{"class":382},"\u002F\u002F 保留原始的 claim 名稱（sub、email…）\n",[376,26064,26065],{"class":378,"line":447},[376,26066,9554],{"class":393},[11,26068,26069],{},"驗證項目包括：",[66,26071,26072,26078,26087,26098],{},[69,26073,26074,26077],{},[23,26075,26076],{},"簽章","：確定 Token 是帳號中心簽發、內容沒有被竄改",[69,26079,26080,26086],{},[23,26081,26082,26085],{},[15,26083,26084],{},"iss","（發行者）","：是我們信任的帳號中心",[69,26088,26089,26093,26094,26097],{},[23,26090,26091,25937],{},[15,26092,25936],{},"：這張 Token 是發給",[23,26095,26096],{},"這個 API"," 的",[69,26099,26100,26106],{},[23,26101,26102,26105],{},[15,26103,26104],{},"exp","（到期時間）","：還沒過期",[100,26108,26110],{"id":26109},"用-sub-當使用者-id不要用-email","用 sub 當使用者 ID，不要用 email",[11,26112,26113],{},"Token 裡有好幾個看起來都能代表使用者的欄位，應該用哪一個存進資料庫，當作資料的擁有者？",[66,26115,26116,26128],{},[69,26117,26118,26124,26125],{},[23,26119,26120,26123],{},[15,26121,26122],{},"sub","（Subject）","：OIDC 規範保證它在同一個 OpenID Provider 內",[23,26126,26127],{},"唯一且永久不變",[69,26129,26130,2276,26132,26135],{},[15,26131,2685],{},[15,26133,26134],{},"preferred_username","：使用者改名、換 email、帳號被回收再分配時都可能改變",[11,26137,26138,26139,55],{},"所以資料表上的「擁有者」欄位應該存 ",[15,26140,26122],{},[36,26142,26144],{"className":3145,"code":26143,"language":3147,"meta":44,"style":44},"var userId = User.FindFirstValue(\"sub\");\nvar rooms  = await _db.ChatRooms.Where(r => r.OwnerId == userId).ToListAsync();\n",[15,26145,26146,26168],{"__ignoreMap":44},[376,26147,26148,26150,26153,26155,26158,26161,26163,26166],{"class":378,"line":379},[376,26149,4684],{"class":3196},[376,26151,26152],{"class":1020}," userId",[376,26154,3313],{"class":3196},[376,26156,26157],{"class":393}," User.",[376,26159,26160],{"class":1020},"FindFirstValue",[376,26162,3220],{"class":393},[376,26164,26165],{"class":414},"\"sub\"",[376,26167,3376],{"class":393},[376,26169,26170,26172,26175,26177,26179,26182,26185,26187,26190,26192,26195,26197,26200,26203],{"class":378,"line":386},[376,26171,4684],{"class":3196},[376,26173,26174],{"class":1020}," rooms",[376,26176,19580],{"class":3196},[376,26178,9704],{"class":3196},[376,26180,26181],{"class":393}," _db.ChatRooms.",[376,26183,26184],{"class":1020},"Where",[376,26186,3220],{"class":393},[376,26188,26189],{"class":1020},"r",[376,26191,3246],{"class":3196},[376,26193,26194],{"class":393}," r.OwnerId ",[376,26196,13221],{"class":3196},[376,26198,26199],{"class":393}," userId).",[376,26201,26202],{"class":1020},"ToListAsync",[376,26204,3532],{"class":393},[100,26206,26208],{"id":26207},"signalr-連線怎麼帶-token","SignalR 連線怎麼帶 Token",[11,26210,26211,26212,26215,26216,26218],{},"WebSocket 在瀏覽器中",[23,26213,26214],{},"無法自訂標頭","，SignalR 的做法是把 Token 放在查詢字串 ",[15,26217,25703],{}," 上。後端要額外告訴 JWT 驗證從哪裡讀：",[36,26220,26222],{"className":3145,"code":26221,"language":3147,"meta":44,"style":44},"options.Events = new JwtBearerEvents\n{\n    OnMessageReceived = context =>\n    {\n        var token = context.Request.Query[\"access_token\"];\n        if (!string.IsNullOrEmpty(token) && context.HttpContext.Request.Path.StartsWithSegments(\"\u002Fhubs\"))\n        {\n            context.Token = token;\n        }\n        return Task.CompletedTask;\n    }\n};\n",[15,26223,26224,26236,26240,26251,26255,26272,26306,26310,26320,26324,26331,26335],{"__ignoreMap":44},[376,26225,26226,26229,26231,26233],{"class":378,"line":379},[376,26227,26228],{"class":393},"options.Events ",[376,26230,3240],{"class":3196},[376,26232,4692],{"class":3196},[376,26234,26235],{"class":1020}," JwtBearerEvents\n",[376,26237,26238],{"class":378,"line":386},[376,26239,3324],{"class":393},[376,26241,26242,26245,26247,26249],{"class":378,"line":397},[376,26243,26244],{"class":393},"    OnMessageReceived ",[376,26246,3240],{"class":3196},[376,26248,3302],{"class":1020},[376,26250,12037],{"class":3196},[376,26252,26253],{"class":378,"line":405},[376,26254,5704],{"class":393},[376,26256,26257,26259,26261,26263,26266,26269],{"class":378,"line":418},[376,26258,13379],{"class":3196},[376,26260,9336],{"class":1020},[376,26262,3313],{"class":3196},[376,26264,26265],{"class":393}," context.Request.Query[",[376,26267,26268],{"class":414},"\"access_token\"",[376,26270,26271],{"class":393},"];\n",[376,26273,26274,26276,26278,26280,26282,26284,26287,26290,26293,26296,26299,26301,26304],{"class":378,"line":429},[376,26275,13337],{"class":3196},[376,26277,5046],{"class":393},[376,26279,5049],{"class":3196},[376,26281,5012],{"class":3196},[376,26283,4735],{"class":393},[376,26285,26286],{"class":1020},"IsNullOrEmpty",[376,26288,26289],{"class":393},"(token) ",[376,26291,26292],{"class":3196},"&&",[376,26294,26295],{"class":393}," context.HttpContext.Request.Path.",[376,26297,26298],{"class":1020},"StartsWithSegments",[376,26300,3220],{"class":393},[376,26302,26303],{"class":414},"\"\u002Fhubs\"",[376,26305,5025],{"class":393},[376,26307,26308],{"class":378,"line":437},[376,26309,5721],{"class":393},[376,26311,26312,26315,26317],{"class":378,"line":447},[376,26313,26314],{"class":393},"            context.Token ",[376,26316,3240],{"class":3196},[376,26318,26319],{"class":393}," token;\n",[376,26321,26322],{"class":378,"line":531},[376,26323,5761],{"class":393},[376,26325,26326,26328],{"class":378,"line":542},[376,26327,3485],{"class":3196},[376,26329,26330],{"class":393}," Task.CompletedTask;\n",[376,26332,26333],{"class":378,"line":552},[376,26334,5766],{"class":393},[376,26336,26337],{"class":378,"line":564},[376,26338,4766],{"class":393},[11,26340,26341,26342,26345,26346,26349,26350,26353,26354,26356],{},"驗證通過後，Hub 裡可以用 ",[15,26343,26344],{},"Context.UserIdentifier"," 取得目前的使用者（預設取自 ",[15,26347,26348],{},"NameIdentifier"," claim，關閉了 claim 對應時，可以實作 ",[15,26351,26352],{},"IUserIdProvider"," 改成讀 ",[15,26355,26122],{},"）。因為查詢字串可能出現在存取日誌裡，記得在反向代理或日誌設定中遮蔽它。",[28,26358,26360],{"id":26359},"使用情境內部-gpt-平台串接公司-sso","使用情境：內部 GPT 平台串接公司 SSO",[11,26362,26363],{},"公司內部的 GPT 平台一開始沒有登入，聊天紀錄只能跟著瀏覽器走。串上公司的 OpenID Connect 之後：",[66,26365,26366,26369,26375,26381],{},[69,26367,26368],{},"同仁直接用公司帳號登入，帳號停用後自然就無法使用，不需要另外維護帳號",[69,26370,26371,26372,26374],{},"聊天室以 ",[15,26373,26122],{}," 作為擁有者存放，換電腦、換瀏覽器，登入後都是自己的聊天室與完整對話",[69,26376,26377,26378,26380],{},"所有查詢、修改、刪除都以 Token 中的 ",[15,26379,26122],{}," 過濾，無法透過改網址看到別人的對話",[69,26382,26383],{},"每段對話的 Token 用量都能歸戶到使用者，掌握各部門的實際用量",[28,26385,1890],{"id":1890},[11,26387,26388,26389,2276,26391,2276,26393,26395,26396,26398],{},"OIDC 是在 OAuth 2.0 之上加了「你是誰」的 id_token；瀏覽器應用使用 Authorization Code 加 PKCE，讓密碼只在帳號中心輸入、授權碼被攔截也換不到 Token；id_token 給前端、access_token 給後端，後端用公鑰驗證簽章、",[15,26390,26084],{},[15,26392,25936],{},[15,26394,26104],{},"，並以永久不變的 ",[15,26397,26122],{}," 作為使用者在系統中的身分。",[1132,26400,26401],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":44,"searchDepth":386,"depth":386,"links":26403},[26404,26405,26406,26407,26411,26412],{"id":25682,"depth":386,"text":25683},{"id":25733,"depth":386,"text":25734},{"id":25889,"depth":386,"text":25890},{"id":25971,"depth":386,"text":25971,"children":26408},[26409,26410],{"id":26109,"depth":397,"text":26110},{"id":26207,"depth":397,"text":26208},{"id":26359,"depth":386,"text":26360},{"id":1890,"depth":386,"text":1890},"2023-10-24",{},"\u002Farticles\u002Fopenid-connect-authorization-code-pkce",{"title":25677,"description":44},"articles\u002Fopenid-connect-authorization-code-pkce",[26419,25693,25857,26420,3902,26421],"OpenID Connect","身分驗證","資訊安全","uD3TK51Ej45O4UNO5VqPKswLn0QpSHxwL8Xa68i4g8E",{"id":26424,"title":26425,"body":26426,"date":27007,"description":44,"excerpt":1156,"extension":1157,"meta":27008,"navigation":1035,"path":27009,"seo":27010,"stem":27011,"tags":27012,"__hash__":27016},"articles\u002Farticles\u002Fincremental-sync-change-column.md","從 MSSQL 增量同步到 Elasticsearch：用異動欄位與 Checkpoint 只同步「變動的資料」",{"type":8,"value":26427,"toc":26992},[26428,26431,26437,26444,26458,26464,26468,26474,26477,26494,26497,26522,26569,26575,26579,26583,26590,26623,26627,26638,26664,26695,26701,26705,26718,26763,26774,26825,26829,26836,26839,26863,26866,26870,26876,26890,26896,26901,26904,26907,26927,26930,26934,26937,26951,26954,26958,26961,26975,26978,26980,26989],[28,26429,26430],{"id":26430},"整批重建的極限",[11,26432,26433,26434],{},"讀取改由 Elasticsearch 提供之後，接下來的問題就是：",[23,26435,26436],{},"MSSQL 的資料改了，索引要怎麼跟上？",[11,26438,26439,26440,26443],{},"最直覺的方式是定期",[23,26441,26442],{},"整批重建","：每隔一段時間把所有資料讀出來，全部重新寫進 Elasticsearch。它的優點是簡單、一定正確，但：",[66,26445,26446,26449,26452],{},[69,26447,26448],{},"物件資料有幾十萬筆，一次重建要幾十分鐘，期間資料庫與 Elasticsearch 的負載都很高",[69,26450,26451],{},"重建的間隔就是資料的延遲：一小時重建一次，經紀人改了價格，前台最久要一小時後才看得到",[69,26453,26454,26455],{},"每次重建處理的資料，",[23,26456,26457],{},"99% 根本沒有變動",[11,26459,26460,26461,114],{},"整批重建仍然需要（第一次建立索引、Mapping 調整時），但日常的更新應該只處理「有變動的資料」，也就是",[23,26462,26463],{},"增量同步",[28,26465,26467],{"id":26466},"核心概念異動欄位-checkpoint","核心概念：異動欄位 + Checkpoint",[11,26469,26470],{},[1952,26471],{"alt":26472,"src":26473},"增量同步示意圖：Houses 資料表依 ChangeMark 排序，上次同步到 1002，本次要同步的範圍是 ChangeMark 大於 1002 的三筆資料（改價、新增、下架）；每一輪同步排程先讀出 Checkpoint，查詢 ChangeMark 大於 1002 的一批資料，逐筆發出異動事件或寫入 Elasticsearch，全部成功後才把 Checkpoint 更新為 1005，失敗就不更新、下一輪重做這一批","\u002Fimages\u002Farticles\u002Fincremental-sync-watermark.svg",[11,26475,26476],{},"增量同步需要兩個東西：",[66,26478,26479,26488],{},[69,26480,26481,26484,26485],{},[23,26482,26483],{},"異動欄位（ChangeMark）","：資料表上的一個欄位，",[23,26486,26487],{},"每次資料新增或修改時，它都會變成一個比之前更大的值",[69,26489,26490,26493],{},[23,26491,26492],{},"Checkpoint（水位）","：記錄「上一次同步到哪個值」，存在一張獨立的資料表裡",[11,26495,26496],{},"每一輪同步的流程：",[211,26498,26499,26505,26516,26519],{},[69,26500,26501,26502],{},"讀出 Checkpoint，例如 ",[15,26503,26504],{},"1002",[69,26506,26507,26508,26511,26512,26515],{},"查詢 ",[15,26509,26510],{},"ChangeMark > 1002"," 的資料，",[23,26513,26514],{},"依 ChangeMark 排序","，一次取一批（例如 500 筆）",[69,26517,26518],{},"把這批資料同步到 Elasticsearch",[69,26520,26521],{},"全部成功後，把 Checkpoint 更新為這批資料中最大的 ChangeMark",[36,26523,26525],{"className":9829,"code":26524,"language":9831,"meta":44,"style":44},"SELECT TOP (@BatchSize) *\nFROM   Houses\nWHERE  ChangeMark > @Checkpoint\nORDER BY ChangeMark;\n",[15,26526,26527,26541,26549,26561],{"__ignoreMap":44},[376,26528,26529,26532,26535,26538],{"class":378,"line":379},[376,26530,26531],{"class":3196},"SELECT",[376,26533,26534],{"class":3196}," TOP",[376,26536,26537],{"class":393}," (@BatchSize) ",[376,26539,26540],{"class":3196},"*\n",[376,26542,26543,26546],{"class":378,"line":386},[376,26544,26545],{"class":3196},"FROM",[376,26547,26548],{"class":393},"   Houses\n",[376,26550,26551,26553,26556,26558],{"class":378,"line":397},[376,26552,9867],{"class":3196},[376,26554,26555],{"class":393},"  ChangeMark ",[376,26557,15391],{"class":3196},[376,26559,26560],{"class":393}," @Checkpoint\n",[376,26562,26563,26566],{"class":378,"line":405},[376,26564,26565],{"class":3196},"ORDER BY",[376,26567,26568],{"class":393}," ChangeMark;\n",[11,26570,26571,26574],{},[15,26572,26573],{},"ChangeMark"," 上要建立索引，這條查詢才會是索引範圍掃描，而不是全表掃描。",[28,26576,26578],{"id":26577},"異動欄位要怎麼產生","異動欄位要怎麼產生？",[100,26580,26582],{"id":26581},"用-datetime-的問題","用 DATETIME 的問題",[11,26584,26585,26586,26589],{},"最常見的異動欄位是 ",[15,26587,26588],{},"UpdatedAt","，但時間戳記有幾個問題：",[66,26591,26592,26608,26617],{},[69,26593,26594,55,26597,26600,26601,26603,26604,26607],{},[23,26595,26596],{},"精度不夠",[15,26598,26599],{},"DATETIME"," 的精度約 3 毫秒，同一批交易更新的多筆資料可能有完全相同的時間。如果剛好在這些資料的中間切批，用 ",[15,26602,15391],{}," 會漏掉同時間的剩餘資料，用 ",[15,26605,26606],{},">="," 又會重複處理",[69,26609,26610,26613,26614,26616],{},[23,26611,26612],{},"依賴每一段程式都記得更新","：只要有一支預存程序、一個手動修正的 SQL 忘了更新 ",[15,26615,26588],{},"，那筆資料就永遠不會被同步",[69,26618,26619,26622],{},[23,26620,26621],{},"時鐘可能倒退","：不同應用程式伺服器的時間不完全一致，寫入的時間不一定是遞增的",[100,26624,26626],{"id":26625},"用-rowversion","用 ROWVERSION",[11,26628,26629,26630,26633,26634,26637],{},"SQL Server 的 ",[15,26631,26632],{},"ROWVERSION","（舊名 ",[15,26635,26636],{},"TIMESTAMP","，但和時間無關）是更好的選擇：",[36,26639,26641],{"className":9829,"code":26640,"language":9831,"meta":44,"style":44},"ALTER TABLE Houses ADD ChangeMark ROWVERSION;\n",[15,26642,26643],{"__ignoreMap":44},[376,26644,26645,26648,26651,26654,26657,26660,26662],{"class":378,"line":379},[376,26646,26647],{"class":3196},"ALTER",[376,26649,26650],{"class":3196}," TABLE",[376,26652,26653],{"class":393}," Houses ",[376,26655,26656],{"class":3196},"ADD",[376,26658,26659],{"class":393}," ChangeMark ",[376,26661,26632],{"class":3196},[376,26663,3428],{"class":393},[66,26665,26666,26678,26685],{},[69,26667,26668,26669],{},"資料庫層級自動維護：",[23,26670,26671,26672,2276,26675,26677],{},"任何 ",[15,26673,26674],{},"INSERT",[15,26676,9838],{},"，不論來自哪個程式、哪支 SQL，都會自動更新",[69,26679,26680,26681,26684],{},"在整個資料庫內",[23,26682,26683],{},"唯一且遞增","，不會有兩筆資料相同的問題",[69,26686,26687,26688,6342,26691,26694],{},"它是 8 位元組的二進位值，程式中可以轉成 ",[15,26689,26690],{},"long",[15,26692,26693],{},"BIGINT","）來比較與儲存",[11,26696,26697,26698,114],{},"如果因為各種原因不能修改資料表結構，也可以和 DBA 約定，在資料異動的流程中一併更新一個遞增的異動欄位——重點是",[23,26699,26700],{},"每一個寫入的路徑都要涵蓋到",[28,26702,26704],{"id":26703},"容易漏資料的地方還沒提交的交易","容易漏資料的地方：還沒提交的交易",[11,26706,26707,26709,26710,26713,26714,26717],{},[15,26708,26632],{}," 是在資料",[23,26711,26712],{},"被修改的當下","就配發的，不是在交易",[23,26715,26716],{},"提交","時。想像這個情況：",[211,26719,26720,26730,26740,26750],{},[69,26721,26722,26723,26726,26727],{},"交易 A 修改了物件 H-0877，拿到 ChangeMark ",[15,26724,26725],{},"1003","，但",[23,26728,26729],{},"還沒提交",[69,26731,26732,26733,26736,26737],{},"交易 B 修改了物件 H-1003，拿到 ",[15,26734,26735],{},"1004","，並且",[23,26738,26739],{},"立刻提交",[69,26741,26742,26743,6342,26745,26747,26748],{},"同步排程執行，讀到已提交的 ",[15,26744,26735],{},[15,26746,26725],{}," 還沒提交，讀不到），把 Checkpoint 推進到 ",[15,26749,26735],{},[69,26751,26752,26753,26755,26756,1540,26758],{},"交易 A 提交，",[15,26754,26725],{}," 出現了——但 Checkpoint 已經在 ",[15,26757,26735],{},[23,26759,26760,26762],{},[15,26761,26725],{}," 永遠不會被同步",[11,26764,26765,26766,26769,26770,26773],{},"SQL Server 提供 ",[15,26767,26768],{},"MIN_ACTIVE_ROWVERSION()","，回傳目前",[23,26771,26772],{},"還在進行中的交易","所持有的最小 ROWVERSION。同步時只處理小於這個值的資料，就不會越過還沒提交的交易：",[36,26775,26777],{"className":9829,"code":26776,"language":9831,"meta":44,"style":44},"SELECT TOP (@BatchSize) *\nFROM   Houses\nWHERE  ChangeMark > @Checkpoint\n  AND  ChangeMark \u003C MIN_ACTIVE_ROWVERSION()\nORDER BY ChangeMark;\n",[15,26778,26779,26789,26795,26805,26819],{"__ignoreMap":44},[376,26780,26781,26783,26785,26787],{"class":378,"line":379},[376,26782,26531],{"class":3196},[376,26784,26534],{"class":3196},[376,26786,26537],{"class":393},[376,26788,26540],{"class":3196},[376,26790,26791,26793],{"class":378,"line":386},[376,26792,26545],{"class":3196},[376,26794,26548],{"class":393},[376,26796,26797,26799,26801,26803],{"class":378,"line":397},[376,26798,9867],{"class":3196},[376,26800,26555],{"class":393},[376,26802,15391],{"class":3196},[376,26804,26560],{"class":393},[376,26806,26807,26810,26812,26814,26817],{"class":378,"line":405},[376,26808,26809],{"class":3196},"  AND",[376,26811,26555],{"class":393},[376,26813,3176],{"class":3196},[376,26815,26816],{"class":560}," MIN_ACTIVE_ROWVERSION",[376,26818,3165],{"class":393},[376,26820,26821,26823],{"class":378,"line":418},[376,26822,26565],{"class":3196},[376,26824,26568],{"class":393},[28,26826,26828],{"id":26827},"刪除要怎麼同步","刪除要怎麼同步？",[11,26830,26831,26832,26835],{},"增量查詢只找得到「還存在的資料」。如果資料被 ",[15,26833,26834],{},"DELETE"," 掉了，查詢根本查不到它，Elasticsearch 裡的文件就會一直留著。",[11,26837,26838],{},"常見的做法：",[66,26840,26841,26857],{},[69,26842,26843,26846,26847,26850,26851,26853,26854,26856],{},[23,26844,26845],{},"軟刪除","：不真的刪除資料，而是設定 ",[15,26848,26849],{},"IsDeleted = 1","。這也是一次 ",[15,26852,9838],{},"，ChangeMark 會更新，同步時看到 ",[15,26855,26849],{}," 就從 Elasticsearch 刪除這份文件",[69,26858,26859,26862],{},[23,26860,26861],{},"刪除紀錄表","：如果一定要實體刪除，用 Trigger 把被刪除的 ID 寫進一張刪除紀錄表（同樣帶 ROWVERSION），同步時一起處理",[11,26864,26865],{},"房仲物件本來就有「下架」狀態，用軟刪除是最自然的做法。",[28,26867,26869],{"id":26868},"失敗時不掉資料先寫入再推進-checkpoint","失敗時不掉資料：先寫入、再推進 Checkpoint",[11,26871,26872,26873,114],{},"順序很重要：",[23,26874,26875],{},"資料同步成功之後，才更新 Checkpoint",[66,26877,26878,26884],{},[69,26879,26880,26881],{},"同步到一半失敗：Checkpoint 沒有推進，下一輪會從同一個位置重做整批，",[23,26882,26883],{},"不會漏",[69,26885,26886,26887,26889],{},"同步成功、但更新 Checkpoint 前程式當掉：下一輪會",[23,26888,6596],{},"這一批",[11,26891,26892,26893,26895],{},"重複處理是可以接受的，前提是寫入 Elasticsearch 的動作是",[23,26894,6606],{},"的：以物件 ID 作為文件 ID，用 Index（存在就覆蓋）而不是 Create；刪除不存在的文件也不視為錯誤。這樣同一批處理兩次，結果和一次相同。",[7381,26897,26898],{},[11,26899,26900],{},"反過來「先推進 Checkpoint、再同步資料」是錯的：同步失敗時，Checkpoint 已經往前走，這一批資料就永遠被跳過了。",[28,26902,26903],{"id":26903},"中間多一層訊息佇列",[11,26905,26906],{},"同步排程可以直接寫 Elasticsearch，也可以只負責「找出變動的資料、發出事件」，把寫入交給訊息佇列後面的消費者：",[66,26908,26909,26915,26921],{},[69,26910,26911,26914],{},[23,26912,26913],{},"偵測與寫入解耦","：排程跑得快，不會因為 Elasticsearch 暫時變慢而拖住整個同步",[69,26916,26917,26920],{},[23,26918,26919],{},"多個下游","：同一筆物件異動，可能有好幾個索引要更新、還有通知服務要發推播，各自訂閱事件即可",[69,26922,26923,26926],{},[23,26924,26925],{},"失敗重試","：單筆寫入失敗時，在佇列中重試或進入死信佇列，不會讓整批同步卡住",[11,26928,26929],{},"這時 Checkpoint 推進的條件就變成「事件成功發布到佇列」（並且開啟 Publisher Confirm），後續的寫入可靠性交給佇列的 Ack 與重試機制。",[28,26931,26933],{"id":26932},"其他選擇cdc-與-change-tracking","其他選擇：CDC 與 Change Tracking",[11,26935,26936],{},"SQL Server 本身也有內建的變更追蹤功能：",[66,26938,26939,26945],{},[69,26940,26941,26944],{},[23,26942,26943],{},"Change Tracking","：記錄「哪些資料列變了」，不記錄變更前後的內容，負擔較輕",[69,26946,26947,26950],{},[23,26948,26949],{},"Change Data Capture（CDC）","：從交易記錄讀出每一筆變更的前後內容，可以搭配 Debezium 之類的工具直接串到訊息佇列",[11,26952,26953],{},"這兩者都由資料庫維護，不會漏掉任何寫入路徑，但需要 DBA 開啟、會增加資料庫的負擔與維運項目。異動欄位的做法則是最容易理解、最容易除錯的起點。",[28,26955,26957],{"id":26956},"使用情境房仲平台的資料同步","使用情境：房仲平台的資料同步",[11,26959,26960],{},"在房仲平台的重構中，買屋、行情、社區三大中台的讀取全部改由 Elasticsearch 提供，資料同步分成兩條路線：",[66,26962,26963,26969],{},[69,26964,26965,26968],{},[23,26966,26967],{},"全量","：由批次程式把 MSSQL 的資料整批轉入 Elasticsearch，用在第一次建立索引或 Mapping 調整後的重建",[69,26970,26971,26974],{},[23,26972,26973],{},"增量","：和 DBA 約定資料異動時一併更新異動欄位，同步排程定期比對 Checkpoint，把有變動的資料包成事件送進 RabbitMQ，再由消費者更新索引",[11,26976,26977],{},"經紀人改價後，前台通常在數秒內就能看到新價格，不再需要等待整批重建。",[28,26979,1890],{"id":1890},[11,26981,26982,26983,26985,26986,26988],{},"增量同步靠「每次異動都會遞增的異動欄位」加上「記住上次同步到哪裡的 Checkpoint」：異動欄位優先用資料庫自動維護的 ",[15,26984,26632],{},"，查詢時用 ",[15,26987,26768],{}," 避開還沒提交的交易，刪除用軟刪除表達；永遠先同步成功、再推進 Checkpoint，並且讓寫入是冪等的，重做也不會出錯。",[1132,26990,26991],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}",{"title":44,"searchDepth":386,"depth":386,"links":26993},[26994,26995,26996,27000,27001,27002,27003,27004,27005,27006],{"id":26430,"depth":386,"text":26430},{"id":26466,"depth":386,"text":26467},{"id":26577,"depth":386,"text":26578,"children":26997},[26998,26999],{"id":26581,"depth":397,"text":26582},{"id":26625,"depth":397,"text":26626},{"id":26703,"depth":386,"text":26704},{"id":26827,"depth":386,"text":26828},{"id":26868,"depth":386,"text":26869},{"id":26903,"depth":386,"text":26903},{"id":26932,"depth":386,"text":26933},{"id":26956,"depth":386,"text":26957},{"id":1890,"depth":386,"text":1890},"2023-09-12",{},"\u002Farticles\u002Fincremental-sync-change-column",{"title":26425,"description":44},"articles\u002Fincremental-sync-change-column",[25673,27013,27014,27015,3039],"MSSQL","資料同步","ETL","0ucwWycVFEurSGHnDBTEuXf_VWqkLei0CEm53b8Nyw4",{"id":27018,"title":27019,"body":27020,"date":27689,"description":44,"excerpt":1156,"extension":1157,"meta":27690,"navigation":1035,"path":17537,"seo":27691,"stem":27692,"tags":27693,"__hash__":27696},"articles\u002Farticles\u002Fsignalr-streaming-gpt.md","用 SignalR 串流做出 ChatGPT 的「逐字出現」：IAsyncEnumerable、取消與多實例部署",{"type":8,"value":27021,"toc":27679},[27022,27026,27033,27039,27060,27066,27070,27073,27126,27132,27136,27151,27384,27400,27403,27424,27428,27438,27542,27552,27556,27569,27575,27582,27585,27588,27591,27596,27603,27615,27620,27627,27647,27651,27654,27665,27667,27676],[28,27023,27025],{"id":27024},"問題等待時間不變但感受差很多","問題：等待時間不變，但感受差很多",[11,27027,27028,27029,27032],{},"大型語言模型是",[23,27030,27031],{},"一個 Token 一個 Token 生成","回答的。一段 800 字的回答，模型可能要花 15～20 秒才全部生成完。",[11,27034,27035],{},[1952,27036],{"alt":27037,"src":27038},"一般 HTTP 與串流的比較：一般 HTTP 要等全部生成完才一次回傳，第一個字大約在 20 秒才出現；串流則是產生一段就推一段，第一個字大約 1 秒就出現。下方是串流的管線：OpenAI API 以 stream 模式回傳，SignalR Hub 以 IAsyncEnumerable 逐段 yield return，透過 WebSocket 推到瀏覽器的 connection.stream","\u002Fimages\u002Farticles\u002Fsignalr-streaming.svg",[66,27040,27041,27050],{},[69,27042,27043,27046,27047],{},[23,27044,27045],{},"一般 HTTP 請求","：後端等 OpenAI 全部生成完、拿到完整回答後才回傳。總時間 20 秒，",[23,27048,27049],{},"第一個字也在 20 秒才出現",[69,27051,27052,27055,27056,27059],{},[23,27053,27054],{},"串流","：OpenAI 每生成一小段就送出一小段，後端立刻轉給瀏覽器。總時間一樣是 20 秒，但",[23,27057,27058],{},"第一個字在 1 秒內就出現","，使用者看著文字一段一段長出來，感覺系統一直在回應",[11,27061,27062,27063,114],{},"對使用者體驗來說，關鍵指標不是總時間，而是",[23,27064,27065],{},"第一個字出現的時間（Time To First Token）",[28,27067,27069],{"id":27068},"為什麼選-signalr","為什麼選 SignalR",[11,27071,27072],{},"要讓伺服器「連續、主動」把資料推給瀏覽器，常見有三種做法：",[132,27074,27075,27085],{},[135,27076,27077],{},[138,27078,27079,27081,27083],{},[141,27080,4829],{},[141,27082,2901],{},[141,27084,6174],{},[152,27086,27087,27100,27113],{},[138,27088,27089,27094,27097],{},[157,27090,27091],{},[23,27092,27093],{},"Server-Sent Events（SSE）",[157,27095,27096],{},"單向：伺服器 → 瀏覽器；基於一般 HTTP",[157,27098,27099],{},"只需要伺服器推送，例如通知、單一問答的串流",[138,27101,27102,27107,27110],{},[157,27103,27104],{},[23,27105,27106],{},"WebSocket",[157,27108,27109],{},"雙向、全雙工，連線建立後雙方隨時可以傳送",[157,27111,27112],{},"需要雙向即時互動，例如聊天、協作",[138,27114,27115,27120,27123],{},[157,27116,27117],{},[23,27118,27119],{},"SignalR",[157,27121,27122],{},"ASP.NET Core 的即時通訊框架，優先使用 WebSocket，不支援時自動降級為 SSE 或 Long Polling",[157,27124,27125],{},".NET 後端、需要雙向通訊，不想自己處理連線管理",[11,27127,27128,27129,27131],{},"聊天室除了串流回覆，還有「使用者中途按停止」「其他分頁同步更新聊天室列表」這些雙向需求。後端是 ASP.NET Core 的情況下，",[23,27130,27119],{}," 幫忙處理了連線建立、重新連線、訊息序列化、降級傳輸等細節，而且直接支援串流。",[28,27133,27135],{"id":27134},"後端hub-方法回傳-iasyncenumerable","後端：Hub 方法回傳 IAsyncEnumerable",[11,27137,27138,27139,27142,27143,27146,27147,27150],{},"SignalR 的 Hub 方法只要回傳 ",[15,27140,27141],{},"IAsyncEnumerable\u003CT>","，就自動變成",[23,27144,27145],{},"串流方法","：每 ",[15,27148,27149],{},"yield return"," 一個值，就推一個值給前端。",[36,27152,27154],{"className":3145,"code":27153,"language":3147,"meta":44,"style":44},"public class ChatHub : Hub\n{\n    private readonly IChatService _chatService;\n\n    public ChatHub(IChatService chatService) => _chatService = chatService;\n\n    public async IAsyncEnumerable\u003Cstring> SendMessage(\n        Guid chatRoomId,\n        string message,\n        [EnumeratorCancellation] CancellationToken cancellationToken)\n    {\n        var answer = new StringBuilder();\n\n        await foreach (var chunk in _chatService.StreamCompletionAsync(chatRoomId, message, cancellationToken))\n        {\n            answer.Append(chunk);\n            yield return chunk;\n        }\n\n        \u002F\u002F 串流結束後才寫入資料庫，而不是每一段都寫一次\n        await _chatService.SaveAnswerAsync(chatRoomId, answer.ToString(), CancellationToken.None);\n    }\n}\n",[15,27155,27156,27170,27174,27188,27192,27218,27222,27242,27251,27261,27277,27281,27297,27301,27326,27330,27341,27346,27350,27354,27359,27376,27380],{"__ignoreMap":44},[376,27157,27158,27160,27162,27165,27167],{"class":378,"line":379},[376,27159,3274],{"class":3196},[376,27161,12180],{"class":3196},[376,27163,27164],{"class":1020}," ChatHub",[376,27166,18523],{"class":393},[376,27168,27169],{"class":1020},"Hub\n",[376,27171,27172],{"class":378,"line":386},[376,27173,3324],{"class":393},[376,27175,27176,27178,27180,27183,27186],{"class":378,"line":397},[376,27177,20728],{"class":3196},[376,27179,12197],{"class":3196},[376,27181,27182],{"class":1020}," IChatService",[376,27184,27185],{"class":1020}," _chatService",[376,27187,3428],{"class":393},[376,27189,27190],{"class":378,"line":405},[376,27191,1036],{"emptyLinePlaceholder":1035},[376,27193,27194,27196,27198,27200,27203,27206,27208,27210,27213,27215],{"class":378,"line":418},[376,27195,12192],{"class":3196},[376,27197,27164],{"class":1020},[376,27199,3220],{"class":393},[376,27201,27202],{"class":1020},"IChatService",[376,27204,27205],{"class":1020}," chatService",[376,27207,5662],{"class":393},[376,27209,5665],{"class":3196},[376,27211,27212],{"class":393}," _chatService ",[376,27214,3240],{"class":3196},[376,27216,27217],{"class":393}," chatService;\n",[376,27219,27220],{"class":378,"line":429},[376,27221,1036],{"emptyLinePlaceholder":1035},[376,27223,27224,27226,27228,27231,27233,27235,27237,27240],{"class":378,"line":437},[376,27225,12192],{"class":3196},[376,27227,3277],{"class":3196},[376,27229,27230],{"class":1020}," IAsyncEnumerable",[376,27232,3176],{"class":393},[376,27234,5012],{"class":3196},[376,27236,3288],{"class":393},[376,27238,27239],{"class":1020},"SendMessage",[376,27241,3294],{"class":393},[376,27243,27244,27247,27249],{"class":378,"line":447},[376,27245,27246],{"class":1020},"        Guid",[376,27248,22053],{"class":1020},[376,27250,3226],{"class":393},[376,27252,27253,27256,27259],{"class":378,"line":531},[376,27254,27255],{"class":3196},"        string",[376,27257,27258],{"class":1020}," message",[376,27260,3226],{"class":393},[376,27262,27263,27266,27269,27271,27273,27275],{"class":378,"line":542},[376,27264,27265],{"class":393},"        [",[376,27267,27268],{"class":1020},"EnumeratorCancellation",[376,27270,22650],{"class":393},[376,27272,3307],{"class":1020},[376,27274,3310],{"class":1020},[376,27276,3319],{"class":393},[376,27278,27279],{"class":378,"line":552},[376,27280,5704],{"class":393},[376,27282,27283,27285,27288,27290,27292,27295],{"class":378,"line":564},[376,27284,13379],{"class":3196},[376,27286,27287],{"class":1020}," answer",[376,27289,3313],{"class":3196},[376,27291,4692],{"class":3196},[376,27293,27294],{"class":1020}," StringBuilder",[376,27296,3532],{"class":393},[376,27298,27299],{"class":378,"line":575},[376,27300,1036],{"emptyLinePlaceholder":1035},[376,27302,27303,27305,27308,27310,27312,27315,27317,27320,27323],{"class":378,"line":583},[376,27304,9572],{"class":3196},[376,27306,27307],{"class":3196}," foreach",[376,27309,5046],{"class":393},[376,27311,4684],{"class":3196},[376,27313,27314],{"class":1020}," chunk",[376,27316,18723],{"class":3196},[376,27318,27319],{"class":393}," _chatService.",[376,27321,27322],{"class":1020},"StreamCompletionAsync",[376,27324,27325],{"class":393},"(chatRoomId, message, cancellationToken))\n",[376,27327,27328],{"class":378,"line":597},[376,27329,5721],{"class":393},[376,27331,27332,27335,27338],{"class":378,"line":605},[376,27333,27334],{"class":393},"            answer.",[376,27336,27337],{"class":1020},"Append",[376,27339,27340],{"class":393},"(chunk);\n",[376,27342,27343],{"class":378,"line":616},[376,27344,27345],{"class":393},"            yield return chunk;\n",[376,27347,27348],{"class":378,"line":624},[376,27349,5761],{"class":393},[376,27351,27352],{"class":378,"line":635},[376,27353,1036],{"emptyLinePlaceholder":1035},[376,27355,27356],{"class":378,"line":5815},[376,27357,27358],{"class":382},"        \u002F\u002F 串流結束後才寫入資料庫，而不是每一段都寫一次\n",[376,27360,27361,27363,27365,27368,27371,27373],{"class":378,"line":7233},[376,27362,9572],{"class":3196},[376,27364,27319],{"class":393},[376,27366,27367],{"class":1020},"SaveAnswerAsync",[376,27369,27370],{"class":393},"(chatRoomId, answer.",[376,27372,9349],{"class":1020},[376,27374,27375],{"class":393},"(), CancellationToken.None);\n",[376,27377,27378],{"class":378,"line":7243},[376,27379,5766],{"class":393},[376,27381,27382],{"class":378,"line":7251},[376,27383,3381],{"class":393},[11,27385,27386,27388,27389,27392,27393,27396,27397,27399],{},[15,27387,27322],{}," 呼叫 OpenAI API 時設定 ",[15,27390,27391],{},"stream: true","，OpenAI 會以 SSE 格式一段一段回傳，每一段解析出 ",[15,27394,27395],{},"delta.content"," 後再 ",[15,27398,27149],{}," 出去。整條管線從 OpenAI 到瀏覽器都是串流，中間沒有任何地方需要「等全部收齊」。",[11,27401,27402],{},"幾個設計重點：",[66,27404,27405,27411],{},[69,27406,27407,27410],{},[23,27408,27409],{},"串流中不寫資料庫","：每一段文字只推給前端，等整段回答結束才一次寫入完整訊息。一段回答可能有幾百段，每一段都寫一次資料庫完全沒有必要",[69,27412,27413,27419,27420,27423],{},[23,27414,27415,27418],{},[15,27416,27417],{},"[EnumeratorCancellation]"," 一定要加","：這個屬性讓前端取消串流時，",[15,27421,27422],{},"cancellationToken"," 會被觸發。沒有加的話，前端取消了，後端還會繼續跑完",[28,27425,27427],{"id":27426},"前端connectionstream","前端：connection.stream()",[11,27429,27430,27431,17363,27434,27437],{},"前端用 ",[15,27432,27433],{},"@microsoft\u002Fsignalr",[15,27435,27436],{},"stream()"," 呼叫串流方法，每收到一段就接在目前的回答後面：",[36,27439,27443],{"className":27440,"code":27441,"language":27442,"meta":44,"style":44},"language-javascript shiki shiki-themes github-dark","const connection = new signalR.HubConnectionBuilder()\n  .withUrl(\"\u002Fhubs\u002Fchat\", { accessTokenFactory: () => getAccessToken() })\n  .withAutomaticReconnect()\n  .build();\n\nawait connection.start();\n\nlet answer = \"\";\nconst subscription = connection.stream(\"SendMessage\", chatRoomId, message)\n  .subscribe({\n    next: (chunk) => {\n      answer += chunk;\n      renderMarkdown(answer);     \u002F\u002F 每次重新渲染，表格、程式碼區塊會逐步成形\n    },\n    complete: () => setStatus(\"done\"),\n    error: (err) => setStatus(\"error\", err),\n  });\n\n\u002F\u002F 使用者按下「停止生成」\nstopButton.onclick = () => subscription.dispose();\n","javascript",[15,27444,27445,27450,27455,27460,27465,27469,27474,27478,27483,27488,27493,27498,27503,27508,27513,27518,27523,27528,27532,27537],{"__ignoreMap":44},[376,27446,27447],{"class":378,"line":379},[376,27448,27449],{},"const connection = new signalR.HubConnectionBuilder()\n",[376,27451,27452],{"class":378,"line":386},[376,27453,27454],{},"  .withUrl(\"\u002Fhubs\u002Fchat\", { accessTokenFactory: () => getAccessToken() })\n",[376,27456,27457],{"class":378,"line":397},[376,27458,27459],{},"  .withAutomaticReconnect()\n",[376,27461,27462],{"class":378,"line":405},[376,27463,27464],{},"  .build();\n",[376,27466,27467],{"class":378,"line":418},[376,27468,1036],{"emptyLinePlaceholder":1035},[376,27470,27471],{"class":378,"line":429},[376,27472,27473],{},"await connection.start();\n",[376,27475,27476],{"class":378,"line":437},[376,27477,1036],{"emptyLinePlaceholder":1035},[376,27479,27480],{"class":378,"line":447},[376,27481,27482],{},"let answer = \"\";\n",[376,27484,27485],{"class":378,"line":531},[376,27486,27487],{},"const subscription = connection.stream(\"SendMessage\", chatRoomId, message)\n",[376,27489,27490],{"class":378,"line":542},[376,27491,27492],{},"  .subscribe({\n",[376,27494,27495],{"class":378,"line":552},[376,27496,27497],{},"    next: (chunk) => {\n",[376,27499,27500],{"class":378,"line":564},[376,27501,27502],{},"      answer += chunk;\n",[376,27504,27505],{"class":378,"line":575},[376,27506,27507],{},"      renderMarkdown(answer);     \u002F\u002F 每次重新渲染，表格、程式碼區塊會逐步成形\n",[376,27509,27510],{"class":378,"line":583},[376,27511,27512],{},"    },\n",[376,27514,27515],{"class":378,"line":597},[376,27516,27517],{},"    complete: () => setStatus(\"done\"),\n",[376,27519,27520],{"class":378,"line":605},[376,27521,27522],{},"    error: (err) => setStatus(\"error\", err),\n",[376,27524,27525],{"class":378,"line":616},[376,27526,27527],{},"  });\n",[376,27529,27530],{"class":378,"line":624},[376,27531,1036],{"emptyLinePlaceholder":1035},[376,27533,27534],{"class":378,"line":635},[376,27535,27536],{},"\u002F\u002F 使用者按下「停止生成」\n",[376,27538,27539],{"class":378,"line":5815},[376,27540,27541],{},"stopButton.onclick = () => subscription.dispose();\n",[11,27543,9742,27544,27547,27548,27551],{},[15,27545,27546],{},"next"," 收到的是",[23,27549,27550],{},"片段","，可能只是半個 Markdown 語法（例如表格只出現了一半）。每次都把「目前為止的完整文字」重新渲染一次，比試著增量渲染片段簡單得多，也不會出現語法錯亂。",[28,27553,27555],{"id":27554},"使用者按停止取消要一路傳到底","使用者按停止：取消要一路傳到底",[11,27557,27558,27559,27562,27563,27565,27566,55],{},"使用者按下「停止生成」時，",[15,27560,27561],{},"subscription.dispose()"," 會通知伺服器取消串流，Hub 方法的 ",[15,27564,27422],{}," 被觸發。這個 Token 要",[23,27567,27568],{},"一路傳下去",[36,27570,27573],{"className":27571,"code":27572,"language":41,"meta":44},[39],"subscription.dispose()\n  → Hub 方法的 cancellationToken\n    → StreamCompletionAsync(..., cancellationToken)\n      → HttpClient 呼叫 OpenAI 的請求被中斷\n",[15,27574,27572],{"__ignoreMap":44},[11,27576,27577,27578,27581],{},"如果中間任何一層沒有把 Token 傳下去，畫面上雖然停了，後端仍然在背景繼續向 OpenAI 收資料，",[23,27579,27580],{},"Token 費用照算","。關掉瀏覽器分頁、網路斷線時，SignalR 連線中斷也會觸發同一個取消流程。",[11,27583,27584],{},"被取消的回答要不要存？比較好的做法是把「已經生成的部分」存下來並標記為中斷，使用者回到聊天室時，還看得到停在哪裡。",[28,27586,27587],{"id":27587},"多台伺服器時要注意的事",[11,27589,27590],{},"服務只有一台的時候一切都很單純，但一開始做水平擴展，SignalR 有兩個常見的問題：",[11,27592,27593],{},[23,27594,27595],{},"1. 連線協商需要黏著（Sticky Session）",[11,27597,27598,27599,27602],{},"SignalR 建立連線時會先發一個 ",[15,27600,27601],{},"negotiate"," 請求，再用拿到的連線 ID 建立正式連線。如果這兩個請求被負載平衡器分到不同的伺服器，連線就會失敗。解法有兩種：",[66,27604,27605,27608],{},[69,27606,27607],{},"負載平衡器（或 Kubernetes Ingress）開啟 Sticky Session",[69,27609,27610,27611,27614],{},"確定所有用戶端都支援 WebSocket 時，前端設定 ",[15,27612,27613],{},"skipNegotiation: true"," 並指定只用 WebSocket，就不需要黏著",[11,27616,27617],{},[23,27618,27619],{},"2. 推播給「其他連線」需要 Backplane",[11,27621,27622,27623,27626],{},"串流回覆是在同一條連線上一問一答，不受影響。但如果要做到「使用者在 A 分頁新增了聊天室，B 分頁的列表也同步更新」，而兩個分頁剛好連到不同的伺服器，伺服器 A 就推不到 B 分頁。這時需要 ",[23,27624,27625],{},"Backplane","（例如 Redis）讓所有伺服器之間轉送訊息：",[36,27628,27630],{"className":3145,"code":27629,"language":3147,"meta":44,"style":44},"builder.Services.AddSignalR().AddStackExchangeRedis(redisConnectionString);\n",[15,27631,27632],{"__ignoreMap":44},[376,27633,27634,27636,27639,27641,27644],{"class":378,"line":379},[376,27635,3159],{"class":393},[376,27637,27638],{"class":1020},"AddSignalR",[376,27640,3526],{"class":393},[376,27642,27643],{"class":1020},"AddStackExchangeRedis",[376,27645,27646],{"class":393},"(redisConnectionString);\n",[28,27648,27650],{"id":27649},"使用情境企業內部-gpt-平台","使用情境：企業內部 GPT 平台",[11,27652,27653],{},"在公司內部的 GPT 平台中，聊天室的對話就是用這套方式實作：",[66,27655,27656,27659,27662],{},[69,27657,27658],{},"前端仿照 ChatGPT 的介面，使用者送出訊息後，回答透過 SignalR 串流逐字出現",[69,27660,27661],{},"後端以串流模式呼叫 GPT-4，每一段都立即推給前端，整段結束後才把完整回答與 Token 用量寫進聊天室",[69,27663,27664],{},"使用者按下停止或關閉分頁時，取消會一路傳到 OpenAI 的請求，避免在背景繼續消耗 Token",[28,27666,1890],{"id":1890},[11,27668,27669,27670,27672,27673,27675],{},"使用者感受到的速度取決於第一個字什麼時候出現，而不是總時間；用 SignalR Hub 回傳 ",[15,27671,27141],{},"，就能把 OpenAI 的串流回覆一段一段推到瀏覽器，記得加上 ",[15,27674,27417],{}," 讓取消一路傳到底、串流結束後才寫資料庫，多台部署時處理好 Sticky Session 與 Backplane。",[1132,27677,27678],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":44,"searchDepth":386,"depth":386,"links":27680},[27681,27682,27683,27684,27685,27686,27687,27688],{"id":27024,"depth":386,"text":27025},{"id":27068,"depth":386,"text":27069},{"id":27134,"depth":386,"text":27135},{"id":27426,"depth":386,"text":27427},{"id":27554,"depth":386,"text":27555},{"id":27587,"depth":386,"text":27587},{"id":27649,"depth":386,"text":27650},{"id":1890,"depth":386,"text":1890},"2023-07-18",{},{"title":27019,"description":44},"articles\u002Fsignalr-streaming-gpt",[27119,27106,3902,27694,27695],"OpenAI","即時通訊","yf7eqCfz8RkW9OZ4FN_qBkuTGX_Ra-if6e0Lq7jtpps",{"id":27698,"title":27699,"body":27700,"date":28889,"description":44,"excerpt":1156,"extension":1157,"meta":28890,"navigation":1035,"path":28891,"seo":28892,"stem":28893,"tags":28894,"__hash__":28898},"articles\u002Farticles\u002Felasticsearch-geo-map-search.md","用 Elasticsearch 做地圖找房：geo_point、geo_bounding_box 與 geo_distance",{"type":8,"value":27701,"toc":28880},[27702,27705,27715,27809,27816,27828,27832,27837,27943,27952,28024,28041,28050,28054,28060,28075,28291,28293,28322,28326,28332,28550,28575,28579,28585,28601,28611,28798,28812,28816,28819,28847,28850,28852,28877],[28,27703,27704],{"id":27704},"為什麼地圖搜尋不適合用關聯式資料庫做",[11,27706,27707,27708,2276,27711,27714],{},"如果物件表上有 ",[15,27709,27710],{},"Lat",[15,27712,27713],{},"Lon"," 兩個欄位，「方框內的物件」可以寫成：",[36,27716,27718],{"className":9829,"code":27717,"language":9831,"meta":44,"style":44},"SELECT * FROM Houses\nWHERE Lat BETWEEN 25.030 AND 25.048\n  AND Lon BETWEEN 121.550 AND 121.580\n  AND Price \u003C= 3000 AND Rooms >= 3;\n",[15,27719,27720,27732,27760,27786],{"__ignoreMap":44},[376,27721,27722,27724,27726,27729],{"class":378,"line":379},[376,27723,26531],{"class":3196},[376,27725,24854],{"class":3196},[376,27727,27728],{"class":3196}," FROM",[376,27730,27731],{"class":393}," Houses\n",[376,27733,27734,27736,27739,27742,27745,27747,27750,27753,27755,27757],{"class":378,"line":386},[376,27735,9867],{"class":3196},[376,27737,27738],{"class":393}," Lat ",[376,27740,27741],{"class":3196},"BETWEEN",[376,27743,27744],{"class":560}," 25",[376,27746,4735],{"class":393},[376,27748,27749],{"class":560},"030",[376,27751,27752],{"class":3196}," AND",[376,27754,27744],{"class":560},[376,27756,4735],{"class":393},[376,27758,27759],{"class":560},"048\n",[376,27761,27762,27764,27767,27769,27772,27774,27777,27779,27781,27783],{"class":378,"line":397},[376,27763,26809],{"class":3196},[376,27765,27766],{"class":393}," Lon ",[376,27768,27741],{"class":3196},[376,27770,27771],{"class":560}," 121",[376,27773,4735],{"class":393},[376,27775,27776],{"class":560},"550",[376,27778,27752],{"class":3196},[376,27780,27771],{"class":560},[376,27782,4735],{"class":393},[376,27784,27785],{"class":560},"580\n",[376,27787,27788,27790,27793,27795,27798,27800,27803,27805,27807],{"class":378,"line":405},[376,27789,26809],{"class":3196},[376,27791,27792],{"class":393}," Price ",[376,27794,13150],{"class":3196},[376,27796,27797],{"class":560}," 3000",[376,27799,27752],{"class":3196},[376,27801,27802],{"class":393}," Rooms ",[376,27804,26606],{"class":3196},[376,27806,9495],{"class":560},[376,27808,3428],{"class":393},[11,27810,27811,27812,27815],{},"資料量小的時候沒什麼問題，但地圖搜尋的特性是：",[23,27813,27814],{},"使用者每拖曳一次、縮放一次，就要重新查詢一次","，還要疊上總價、房數、屋齡等篩選條件。兩個欄位的範圍條件很難同時有效利用一般的 B-Tree 索引；「某個點方圓一公里內」更需要計算距離，每一筆都算一次就是全表掃描。",[11,27817,27818,27819,27822,27823,27827],{},"Elasticsearch 的 ",[15,27820,27821],{},"geo_point"," 欄位專門為這種查詢設計，地理條件可以和其他篩選條件一起在索引層完成。基本的索引、Mapping 與 Query DSL 概念可以參考 ",[119,27824,27826],{"href":27825},"\u002Farticles\u002Felasticsearch-getting-started","Elasticsearch 入門"," 這篇。",[28,27829,27831],{"id":27830},"geo_point先把座標存對","geo_point：先把座標存對",[11,27833,27834,27835,55],{},"Mapping 中把座標欄位宣告為 ",[15,27836,27821],{},[36,27838,27840],{"className":25359,"code":27839,"language":25361,"meta":44,"style":44},"PUT \u002Fhouses\n{\n  \"mappings\": {\n    \"properties\": {\n      \"title\":    { \"type\": \"text\" },\n      \"price\":    { \"type\": \"integer\" },\n      \"rooms\":    { \"type\": \"integer\" },\n      \"location\": { \"type\": \"geo_point\" }\n    }\n  }\n}\n",[15,27841,27842,27847,27851,27859,27866,27884,27900,27915,27931,27935,27939],{"__ignoreMap":44},[376,27843,27844],{"class":378,"line":379},[376,27845,27846],{"class":393},"PUT \u002Fhouses\n",[376,27848,27849],{"class":378,"line":386},[376,27850,3324],{"class":393},[376,27852,27853,27856],{"class":378,"line":397},[376,27854,27855],{"class":560},"  \"mappings\"",[376,27857,27858],{"class":393},": {\n",[376,27860,27861,27864],{"class":378,"line":405},[376,27862,27863],{"class":560},"    \"properties\"",[376,27865,27858],{"class":393},[376,27867,27868,27871,27874,27877,27879,27882],{"class":378,"line":418},[376,27869,27870],{"class":560},"      \"title\"",[376,27872,27873],{"class":393},":    { ",[376,27875,27876],{"class":560},"\"type\"",[376,27878,411],{"class":393},[376,27880,27881],{"class":414},"\"text\"",[376,27883,25454],{"class":393},[376,27885,27886,27889,27891,27893,27895,27898],{"class":378,"line":429},[376,27887,27888],{"class":560},"      \"price\"",[376,27890,27873],{"class":393},[376,27892,27876],{"class":560},[376,27894,411],{"class":393},[376,27896,27897],{"class":414},"\"integer\"",[376,27899,25454],{"class":393},[376,27901,27902,27905,27907,27909,27911,27913],{"class":378,"line":437},[376,27903,27904],{"class":560},"      \"rooms\"",[376,27906,27873],{"class":393},[376,27908,27876],{"class":560},[376,27910,411],{"class":393},[376,27912,27897],{"class":414},[376,27914,25454],{"class":393},[376,27916,27917,27920,27922,27924,27926,27929],{"class":378,"line":447},[376,27918,27919],{"class":560},"      \"location\"",[376,27921,3589],{"class":393},[376,27923,27876],{"class":560},[376,27925,411],{"class":393},[376,27927,27928],{"class":414},"\"geo_point\"",[376,27930,3610],{"class":393},[376,27932,27933],{"class":378,"line":531},[376,27934,5766],{"class":393},[376,27936,27937],{"class":378,"line":542},[376,27938,15700],{"class":393},[376,27940,27941],{"class":378,"line":552},[376,27942,3381],{"class":393},[11,27944,27945,27946,27948,27949,55],{},"寫入時，",[15,27947,27821],{}," 支援好幾種格式，",[23,27950,27951],{},"最容易出錯的是順序",[36,27953,27955],{"className":25359,"code":27954,"language":25361,"meta":44,"style":44},"{ \"location\": { \"lat\": 25.0330, \"lon\": 121.5654 } }   \u002F\u002F 物件格式：清楚，推薦\n{ \"location\": \"25.0330,121.5654\" }                    \u002F\u002F 字串格式：lat,lon\n{ \"location\": [121.5654, 25.0330] }                   \u002F\u002F 陣列格式：lon, lat（順序相反！）\n",[15,27956,27957,27987,28004],{"__ignoreMap":44},[376,27958,27959,27962,27965,27967,27969,27971,27973,27975,27977,27979,27981,27984],{"class":378,"line":379},[376,27960,27961],{"class":393},"{ ",[376,27963,27964],{"class":560},"\"location\"",[376,27966,3589],{"class":393},[376,27968,25501],{"class":560},[376,27970,411],{"class":393},[376,27972,25506],{"class":560},[376,27974,3188],{"class":393},[376,27976,25511],{"class":560},[376,27978,411],{"class":393},[376,27980,25516],{"class":560},[376,27982,27983],{"class":393}," } }   ",[376,27985,27986],{"class":382},"\u002F\u002F 物件格式：清楚，推薦\n",[376,27988,27989,27991,27993,27995,27998,28001],{"class":378,"line":386},[376,27990,27961],{"class":393},[376,27992,27964],{"class":560},[376,27994,411],{"class":393},[376,27996,27997],{"class":414},"\"25.0330,121.5654\"",[376,27999,28000],{"class":393}," }                    ",[376,28002,28003],{"class":382},"\u002F\u002F 字串格式：lat,lon\n",[376,28005,28006,28008,28010,28012,28014,28016,28018,28021],{"class":378,"line":397},[376,28007,27961],{"class":393},[376,28009,27964],{"class":560},[376,28011,7712],{"class":393},[376,28013,25516],{"class":560},[376,28015,3188],{"class":393},[376,28017,25506],{"class":560},[376,28019,28020],{"class":393},"] }                   ",[376,28022,28023],{"class":382},"\u002F\u002F 陣列格式：lon, lat（順序相反！）\n",[11,28025,28026,28027,28032,28033,28036,28037,28040],{},"陣列格式遵循 GeoJSON 的慣例，是 ",[23,28028,28029],{},[15,28030,28031],{},"[經度, 緯度]","，和字串格式剛好相反。如果以為陣列也是「緯度在前」而寫成 ",[15,28034,28035],{},"[25.0330, 121.5654]","，緯度就變成 121.5654——緯度只能介於 −90 到 90，這筆資料會直接寫入失敗；更麻煩的是兩個值剛好都在合法範圍內的情況，資料會寫入成功，但物件默默被放到地球的另一個地方。",[23,28038,28039],{},"統一使用物件格式","，可以完全避開這個問題。",[7381,28042,28043],{},[11,28044,28045,28046,28049],{},"Mapping 一旦建立，欄位型別就不能修改。如果座標已經用 ",[15,28047,28048],{},"float"," 存進去了，必須建新索引、重新寫入資料，所以第一次建立索引時就要決定好。",[28,28051,28053],{"id":28052},"geo_bounding_box地圖可視範圍內的物件","geo_bounding_box：地圖可視範圍內的物件",[11,28055,28056],{},[1952,28057],{"alt":28058,"src":28059},"兩種地理查詢示意圖：左邊 geo_bounding_box 以地圖可視範圍的左上角與右下角座標畫出方框，只回傳框內的物件，框外的物件不回傳；右邊 geo_distance 以目前物件為圓心、半徑 1 公里畫圓，圓內的物件依距離由近到遠排序，圓外的物件排除","\u002Fimages\u002Farticles\u002Fes-geo-queries.svg",[11,28061,28062,28063,28066,28067,28070,28071,28074],{},"地圖元件（Google Maps、Leaflet 等）都可以在拖曳、縮放結束後取得目前畫面的邊界。把",[23,28064,28065],{},"左上角","與",[23,28068,28069],{},"右下角","的座標送到後端，組成 ",[15,28072,28073],{},"geo_bounding_box"," 查詢：",[36,28076,28078],{"className":25359,"code":28077,"language":25361,"meta":44,"style":44},"GET \u002Fhouses\u002F_search\n{\n  \"size\": 200,\n  \"_source\": [\"id\", \"price\", \"location\"],\n  \"query\": {\n    \"bool\": {\n      \"filter\": [\n        {\n          \"geo_bounding_box\": {\n            \"location\": {\n              \"top_left\":     { \"lat\": 25.0480, \"lon\": 121.5500 },\n              \"bottom_right\": { \"lat\": 25.0300, \"lon\": 121.5800 }\n            }\n          }\n        },\n        { \"range\": { \"price\": { \"lte\": 3000 } } },\n        { \"range\": { \"rooms\": { \"gte\": 3 } } }\n      ]\n    }\n  }\n}\n",[15,28079,28080,28085,28089,28100,28121,28128,28135,28143,28147,28154,28161,28187,28212,28216,28221,28226,28251,28274,28279,28283,28287],{"__ignoreMap":44},[376,28081,28082],{"class":378,"line":379},[376,28083,28084],{"class":393},"GET \u002Fhouses\u002F_search\n",[376,28086,28087],{"class":378,"line":386},[376,28088,3324],{"class":393},[376,28090,28091,28094,28096,28098],{"class":378,"line":397},[376,28092,28093],{"class":560},"  \"size\"",[376,28095,411],{"class":393},[376,28097,23259],{"class":560},[376,28099,3226],{"class":393},[376,28101,28102,28105,28107,28109,28111,28114,28116,28118],{"class":378,"line":405},[376,28103,28104],{"class":560},"  \"_source\"",[376,28106,7712],{"class":393},[376,28108,25436],{"class":414},[376,28110,3188],{"class":393},[376,28112,28113],{"class":414},"\"price\"",[376,28115,3188],{"class":393},[376,28117,27964],{"class":414},[376,28119,28120],{"class":393},"],\n",[376,28122,28123,28126],{"class":378,"line":418},[376,28124,28125],{"class":560},"  \"query\"",[376,28127,27858],{"class":393},[376,28129,28130,28133],{"class":378,"line":429},[376,28131,28132],{"class":560},"    \"bool\"",[376,28134,27858],{"class":393},[376,28136,28137,28140],{"class":378,"line":437},[376,28138,28139],{"class":560},"      \"filter\"",[376,28141,28142],{"class":393},": [\n",[376,28144,28145],{"class":378,"line":447},[376,28146,5721],{"class":393},[376,28148,28149,28152],{"class":378,"line":531},[376,28150,28151],{"class":560},"          \"geo_bounding_box\"",[376,28153,27858],{"class":393},[376,28155,28156,28159],{"class":378,"line":542},[376,28157,28158],{"class":560},"            \"location\"",[376,28160,27858],{"class":393},[376,28162,28163,28166,28169,28171,28173,28176,28178,28180,28182,28185],{"class":378,"line":552},[376,28164,28165],{"class":560},"              \"top_left\"",[376,28167,28168],{"class":393},":     { ",[376,28170,25501],{"class":560},[376,28172,411],{"class":393},[376,28174,28175],{"class":560},"25.0480",[376,28177,3188],{"class":393},[376,28179,25511],{"class":560},[376,28181,411],{"class":393},[376,28183,28184],{"class":560},"121.5500",[376,28186,25454],{"class":393},[376,28188,28189,28192,28194,28196,28198,28201,28203,28205,28207,28210],{"class":378,"line":564},[376,28190,28191],{"class":560},"              \"bottom_right\"",[376,28193,3589],{"class":393},[376,28195,25501],{"class":560},[376,28197,411],{"class":393},[376,28199,28200],{"class":560},"25.0300",[376,28202,3188],{"class":393},[376,28204,25511],{"class":560},[376,28206,411],{"class":393},[376,28208,28209],{"class":560},"121.5800",[376,28211,3610],{"class":393},[376,28213,28214],{"class":378,"line":575},[376,28215,9545],{"class":393},[376,28217,28218],{"class":378,"line":583},[376,28219,28220],{"class":393},"          }\n",[376,28222,28223],{"class":378,"line":597},[376,28224,28225],{"class":393},"        },\n",[376,28227,28228,28231,28234,28236,28238,28240,28243,28245,28248],{"class":378,"line":605},[376,28229,28230],{"class":393},"        { ",[376,28232,28233],{"class":560},"\"range\"",[376,28235,3589],{"class":393},[376,28237,28113],{"class":560},[376,28239,3589],{"class":393},[376,28241,28242],{"class":560},"\"lte\"",[376,28244,411],{"class":393},[376,28246,28247],{"class":560},"3000",[376,28249,28250],{"class":393}," } } },\n",[376,28252,28253,28255,28257,28259,28262,28264,28267,28269,28271],{"class":378,"line":616},[376,28254,28230],{"class":393},[376,28256,28233],{"class":560},[376,28258,3589],{"class":393},[376,28260,28261],{"class":560},"\"rooms\"",[376,28263,3589],{"class":393},[376,28265,28266],{"class":560},"\"gte\"",[376,28268,411],{"class":393},[376,28270,6235],{"class":560},[376,28272,28273],{"class":393}," } } }\n",[376,28275,28276],{"class":378,"line":624},[376,28277,28278],{"class":393},"      ]\n",[376,28280,28281],{"class":378,"line":635},[376,28282,5766],{"class":393},[376,28284,28285],{"class":378,"line":5815},[376,28286,15700],{"class":393},[376,28288,28289],{"class":378,"line":7233},[376,28290,3381],{"class":393},[11,28292,21239],{},[66,28294,28295,28306,28316],{},[69,28296,28297,28302,28303,28305],{},[23,28298,28299,28300],{},"所有條件都放在 ",[15,28301,15207],{},"：地理範圍與篩選條件都是「符合／不符合」，不需要計算相關性分數。放在 ",[15,28304,15207],{}," 中，Elasticsearch 會跳過評分，並且可以快取篩選結果",[69,28307,28308,28311,28312,28315],{},[23,28309,28310],{},"只回傳地圖需要的欄位","：地圖上的價格標籤只需要 ID、價格和座標，用 ",[15,28313,28314],{},"_source"," 過濾掉標題、描述、照片等大欄位，回應大小可以小好幾倍。使用者點了某個標籤，再查那一筆的詳細資料",[69,28317,28318,28321],{},[23,28319,28320],{},"限制回傳數量","：使用者把地圖縮小到整個台北市時，框內可能有上萬筆物件。一次全部丟給前端，傳輸慢、地圖也畫不動（下面會談怎麼處理）",[28,28323,28325],{"id":28324},"geo_distance附近物件推薦","geo_distance：附近物件推薦",[11,28327,28328,28329,55],{},"物件詳細頁的「附近物件」，是以目前物件為圓心，找出一定半徑內的其他物件，再",[23,28330,28331],{},"依距離排序",[36,28333,28335],{"className":25359,"code":28334,"language":25361,"meta":44,"style":44},"GET \u002Fhouses\u002F_search\n{\n  \"size\": 6,\n  \"query\": {\n    \"bool\": {\n      \"filter\": [\n        {\n          \"geo_distance\": {\n            \"distance\": \"1km\",\n            \"location\": { \"lat\": 25.0330, \"lon\": 121.5654 }\n          }\n        }\n      ],\n      \"must_not\": [\n        { \"term\": { \"id\": \"H-102938\" } }\n      ]\n    }\n  },\n  \"sort\": [\n    {\n      \"_geo_distance\": {\n        \"location\": { \"lat\": 25.0330, \"lon\": 121.5654 },\n        \"order\": \"asc\",\n        \"unit\": \"m\"\n      }\n    }\n  ]\n}\n",[15,28336,28337,28341,28345,28355,28361,28367,28373,28377,28384,28396,28418,28422,28426,28431,28438,28456,28460,28464,28469,28476,28480,28487,28510,28522,28532,28537,28541,28546],{"__ignoreMap":44},[376,28338,28339],{"class":378,"line":379},[376,28340,28084],{"class":393},[376,28342,28343],{"class":378,"line":386},[376,28344,3324],{"class":393},[376,28346,28347,28349,28351,28353],{"class":378,"line":397},[376,28348,28093],{"class":560},[376,28350,411],{"class":393},[376,28352,5478],{"class":560},[376,28354,3226],{"class":393},[376,28356,28357,28359],{"class":378,"line":405},[376,28358,28125],{"class":560},[376,28360,27858],{"class":393},[376,28362,28363,28365],{"class":378,"line":418},[376,28364,28132],{"class":560},[376,28366,27858],{"class":393},[376,28368,28369,28371],{"class":378,"line":429},[376,28370,28139],{"class":560},[376,28372,28142],{"class":393},[376,28374,28375],{"class":378,"line":437},[376,28376,5721],{"class":393},[376,28378,28379,28382],{"class":378,"line":447},[376,28380,28381],{"class":560},"          \"geo_distance\"",[376,28383,27858],{"class":393},[376,28385,28386,28389,28391,28394],{"class":378,"line":531},[376,28387,28388],{"class":560},"            \"distance\"",[376,28390,411],{"class":393},[376,28392,28393],{"class":414},"\"1km\"",[376,28395,3226],{"class":393},[376,28397,28398,28400,28402,28404,28406,28408,28410,28412,28414,28416],{"class":378,"line":542},[376,28399,28158],{"class":560},[376,28401,3589],{"class":393},[376,28403,25501],{"class":560},[376,28405,411],{"class":393},[376,28407,25506],{"class":560},[376,28409,3188],{"class":393},[376,28411,25511],{"class":560},[376,28413,411],{"class":393},[376,28415,25516],{"class":560},[376,28417,3610],{"class":393},[376,28419,28420],{"class":378,"line":552},[376,28421,28220],{"class":393},[376,28423,28424],{"class":378,"line":564},[376,28425,5761],{"class":393},[376,28427,28428],{"class":378,"line":575},[376,28429,28430],{"class":393},"      ],\n",[376,28432,28433,28436],{"class":378,"line":583},[376,28434,28435],{"class":560},"      \"must_not\"",[376,28437,28142],{"class":393},[376,28439,28440,28442,28445,28447,28449,28451,28453],{"class":378,"line":597},[376,28441,28230],{"class":393},[376,28443,28444],{"class":560},"\"term\"",[376,28446,3589],{"class":393},[376,28448,25436],{"class":560},[376,28450,411],{"class":393},[376,28452,25377],{"class":414},[376,28454,28455],{"class":393}," } }\n",[376,28457,28458],{"class":378,"line":605},[376,28459,28278],{"class":393},[376,28461,28462],{"class":378,"line":616},[376,28463,5766],{"class":393},[376,28465,28466],{"class":378,"line":624},[376,28467,28468],{"class":393},"  },\n",[376,28470,28471,28474],{"class":378,"line":635},[376,28472,28473],{"class":560},"  \"sort\"",[376,28475,28142],{"class":393},[376,28477,28478],{"class":378,"line":5815},[376,28479,5704],{"class":393},[376,28481,28482,28485],{"class":378,"line":7233},[376,28483,28484],{"class":560},"      \"_geo_distance\"",[376,28486,27858],{"class":393},[376,28488,28489,28492,28494,28496,28498,28500,28502,28504,28506,28508],{"class":378,"line":7243},[376,28490,28491],{"class":560},"        \"location\"",[376,28493,3589],{"class":393},[376,28495,25501],{"class":560},[376,28497,411],{"class":393},[376,28499,25506],{"class":560},[376,28501,3188],{"class":393},[376,28503,25511],{"class":560},[376,28505,411],{"class":393},[376,28507,25516],{"class":560},[376,28509,25454],{"class":393},[376,28511,28512,28515,28517,28520],{"class":378,"line":7251},[376,28513,28514],{"class":560},"        \"order\"",[376,28516,411],{"class":393},[376,28518,28519],{"class":414},"\"asc\"",[376,28521,3226],{"class":393},[376,28523,28524,28527,28529],{"class":378,"line":7721},[376,28525,28526],{"class":560},"        \"unit\"",[376,28528,411],{"class":393},[376,28530,28531],{"class":414},"\"m\"\n",[376,28533,28534],{"class":378,"line":7729},[376,28535,28536],{"class":393},"      }\n",[376,28538,28539],{"class":378,"line":7740},[376,28540,5766],{"class":393},[376,28542,28543],{"class":378,"line":9569},[376,28544,28545],{"class":393},"  ]\n",[376,28547,28548],{"class":378,"line":9581},[376,28549,3381],{"class":393},[66,28551,28552,28558,28572],{},[69,28553,28554,28557],{},[15,28555,28556],{},"must_not"," 排除目前這一筆物件自己，否則距離 0 公尺的它永遠排第一",[69,28559,28560,28563,28564,28567,28568,28571],{},[15,28561,28562],{},"_geo_distance"," 排序時，每一筆結果的 ",[15,28565,28566],{},"sort"," 欄位會帶回",[23,28569,28570],{},"實際距離","，前端可以直接顯示「距離 320 公尺」，不需要自己再算一次",[69,28573,28574],{},"半徑內的物件太少時（例如郊區），可以在後端判斷筆數不足就放大半徑再查一次，避免推薦區塊空空的",[28,28576,28578],{"id":28577},"縮小到整個城市時用聚合代替點","縮小到整個城市時：用聚合代替點",[11,28580,28581,28582,55],{},"地圖縮得越小，框內的物件越多。這時候在地圖上畫幾千個標籤，不但看不清楚，瀏覽器也會很吃力。常見的做法是",[23,28583,28584],{},"依縮放層級切換顯示方式",[66,28586,28587,28595],{},[69,28588,28589,17687,28592,28594],{},[23,28590,28591],{},"放大（街道層級）",[15,28593,28073],{}," 回傳個別物件，顯示價格標籤",[69,28596,28597,28600],{},[23,28598,28599],{},"縮小（行政區、城市層級）","：改用聚合，回傳「每一塊區域有幾筆物件」，在地圖上顯示數量泡泡",[11,28602,28603,28606,28607,28610],{},[15,28604,28605],{},"geotile_grid"," 聚合會依地圖圖磚（Tile）切分區域，",[15,28608,28609],{},"precision"," 對應地圖的縮放層級：",[36,28612,28614],{"className":25359,"code":28613,"language":25361,"meta":44,"style":44},"GET \u002Fhouses\u002F_search\n{\n  \"size\": 0,\n  \"query\": {\n    \"geo_bounding_box\": {\n      \"location\": {\n        \"top_left\":     { \"lat\": 25.21, \"lon\": 121.45 },\n        \"bottom_right\": { \"lat\": 24.96, \"lon\": 121.67 }\n      }\n    }\n  },\n  \"aggs\": {\n    \"grid\": {\n      \"geotile_grid\": { \"field\": \"location\", \"precision\": 13 },\n      \"aggs\": {\n        \"center\": { \"geo_centroid\": { \"field\": \"location\" } }\n      }\n    }\n  }\n}\n",[15,28615,28616,28620,28624,28634,28640,28647,28653,28678,28703,28707,28711,28715,28722,28729,28755,28762,28782,28786,28790,28794],{"__ignoreMap":44},[376,28617,28618],{"class":378,"line":379},[376,28619,28084],{"class":393},[376,28621,28622],{"class":378,"line":386},[376,28623,3324],{"class":393},[376,28625,28626,28628,28630,28632],{"class":378,"line":397},[376,28627,28093],{"class":560},[376,28629,411],{"class":393},[376,28631,4975],{"class":560},[376,28633,3226],{"class":393},[376,28635,28636,28638],{"class":378,"line":405},[376,28637,28125],{"class":560},[376,28639,27858],{"class":393},[376,28641,28642,28645],{"class":378,"line":418},[376,28643,28644],{"class":560},"    \"geo_bounding_box\"",[376,28646,27858],{"class":393},[376,28648,28649,28651],{"class":378,"line":429},[376,28650,27919],{"class":560},[376,28652,27858],{"class":393},[376,28654,28655,28658,28660,28662,28664,28667,28669,28671,28673,28676],{"class":378,"line":437},[376,28656,28657],{"class":560},"        \"top_left\"",[376,28659,28168],{"class":393},[376,28661,25501],{"class":560},[376,28663,411],{"class":393},[376,28665,28666],{"class":560},"25.21",[376,28668,3188],{"class":393},[376,28670,25511],{"class":560},[376,28672,411],{"class":393},[376,28674,28675],{"class":560},"121.45",[376,28677,25454],{"class":393},[376,28679,28680,28683,28685,28687,28689,28692,28694,28696,28698,28701],{"class":378,"line":447},[376,28681,28682],{"class":560},"        \"bottom_right\"",[376,28684,3589],{"class":393},[376,28686,25501],{"class":560},[376,28688,411],{"class":393},[376,28690,28691],{"class":560},"24.96",[376,28693,3188],{"class":393},[376,28695,25511],{"class":560},[376,28697,411],{"class":393},[376,28699,28700],{"class":560},"121.67",[376,28702,3610],{"class":393},[376,28704,28705],{"class":378,"line":531},[376,28706,28536],{"class":393},[376,28708,28709],{"class":378,"line":542},[376,28710,5766],{"class":393},[376,28712,28713],{"class":378,"line":552},[376,28714,28468],{"class":393},[376,28716,28717,28720],{"class":378,"line":564},[376,28718,28719],{"class":560},"  \"aggs\"",[376,28721,27858],{"class":393},[376,28723,28724,28727],{"class":378,"line":575},[376,28725,28726],{"class":560},"    \"grid\"",[376,28728,27858],{"class":393},[376,28730,28731,28734,28736,28739,28741,28743,28745,28748,28750,28753],{"class":378,"line":583},[376,28732,28733],{"class":560},"      \"geotile_grid\"",[376,28735,3589],{"class":393},[376,28737,28738],{"class":560},"\"field\"",[376,28740,411],{"class":393},[376,28742,27964],{"class":414},[376,28744,3188],{"class":393},[376,28746,28747],{"class":560},"\"precision\"",[376,28749,411],{"class":393},[376,28751,28752],{"class":560},"13",[376,28754,25454],{"class":393},[376,28756,28757,28760],{"class":378,"line":597},[376,28758,28759],{"class":560},"      \"aggs\"",[376,28761,27858],{"class":393},[376,28763,28764,28767,28769,28772,28774,28776,28778,28780],{"class":378,"line":605},[376,28765,28766],{"class":560},"        \"center\"",[376,28768,3589],{"class":393},[376,28770,28771],{"class":560},"\"geo_centroid\"",[376,28773,3589],{"class":393},[376,28775,28738],{"class":560},[376,28777,411],{"class":393},[376,28779,27964],{"class":414},[376,28781,28455],{"class":393},[376,28783,28784],{"class":378,"line":616},[376,28785,28536],{"class":393},[376,28787,28788],{"class":378,"line":624},[376,28789,5766],{"class":393},[376,28791,28792],{"class":378,"line":635},[376,28793,15700],{"class":393},[376,28795,28796],{"class":378,"line":5815},[376,28797,3381],{"class":393},[11,28799,28800,28803,28804,28807,28808,28811],{},[15,28801,28802],{},"geo_centroid"," 計算每一格內物件的中心點，把數量泡泡畫在物件實際集中的位置，而不是格子的正中央。另一種做法是依「行政區」這類 ",[15,28805,28806],{},"keyword"," 欄位做 ",[15,28809,28810],{},"terms"," 聚合，把泡泡畫在行政區的中心，對使用者來說更直覺。",[28,28813,28815],{"id":28814},"使用情境房仲平台的地圖找房","使用情境：房仲平台的地圖找房",[11,28817,28818],{},"在房仲平台的買屋與成交行情頻道中，地圖找房的流程是：",[211,28820,28821,28828,28834,28837],{},[69,28822,28823,28824,28827],{},"使用者拖曳或縮放地圖，前端在動作",[23,28825,28826],{},"結束後","（加上 300ms 左右的 Debounce，避免拖曳過程中連續發出請求）送出目前的邊界座標與篩選條件",[69,28829,28830,28831,28833],{},"中台依縮放層級決定查詢方式：街道層級用 ",[15,28832,28073],{}," 回傳物件；較大範圍改回傳聚合結果",[69,28835,28836],{},"回應只帶 ID、價格、座標，前端畫出價格標籤；點擊標籤時再查詢物件詳細資料",[69,28838,28839,28840,28843,28844,28846],{},"物件詳細頁以 ",[15,28841,28842],{},"geo_distance"," 加上 ",[15,28845,28562],{}," 排序，推薦附近 1 公里內的物件",[11,28848,28849],{},"成交行情頻道用的是同一套做法，差別只在索引的內容是實價登錄的成交紀錄，標籤上顯示的是成交單價。",[28,28851,1890],{"id":1890},[11,28853,28854,28855,28857,28858,28861,28862,28864,28865,28867,28868,28870,28871,28873,28874,28876],{},"座標用 ",[15,28856,27821],{}," 存、而且統一用 ",[15,28859,28860],{},"{ lat, lon }"," 物件格式避免經緯度寫反；地圖可視範圍用 ",[15,28863,28073],{},"、附近推薦用 ",[15,28866,28842],{}," 搭配 ",[15,28869,28562],{}," 排序，條件全部放在 ",[15,28872,15207],{}," 並只回傳必要欄位；地圖縮得很小時，改用 ",[15,28875,28605],{}," 聚合回傳數量，而不是把上萬個點丟給前端。",[1132,28878,28879],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",{"title":44,"searchDepth":386,"depth":386,"links":28881},[28882,28883,28884,28885,28886,28887,28888],{"id":27704,"depth":386,"text":27704},{"id":27830,"depth":386,"text":27831},{"id":28052,"depth":386,"text":28053},{"id":28324,"depth":386,"text":28325},{"id":28577,"depth":386,"text":28578},{"id":28814,"depth":386,"text":28815},{"id":1890,"depth":386,"text":1890},"2023-05-30",{},"\u002Farticles\u002Felasticsearch-geo-map-search",{"title":27699,"description":44},"articles\u002Felasticsearch-geo-map-search",[25673,28895,28896,28897],"GEO","地圖搜尋","搜尋引擎","9gOrT8PmPx8ukhOYza4liQg1M2ii7L1tdj6IW_Tfudk",{"id":28900,"title":28901,"body":28902,"date":30077,"description":44,"excerpt":1156,"extension":1157,"meta":30078,"navigation":1035,"path":12413,"seo":30079,"stem":30080,"tags":30081,"__hash__":30083},"articles\u002Farticles\u002Fredis-data-structures-use-cases.md","Redis 不只是快取：五種資料結構、各自的使用情境與上線前要知道的事",{"type":8,"value":28903,"toc":30058},[28904,28908,28915,28938,28948,28955,28958,28964,28968,28971,29030,29034,29072,29076,29079,29131,29135,29156,29163,29167,29170,29222,29226,29254,29258,29261,29313,29317,29350,29354,29361,29416,29420,29456,29459,29484,29488,29491,29804,29814,29818,29821,29833,29836,29846,29925,29934,29937,29940,29961,29964,29967,30047,30049,30056],[28,28905,28907],{"id":28906},"redis-是什麼","Redis 是什麼",[11,28909,28910,28911,28914],{},"Redis 是一個",[23,28912,28913],{},"把資料放在記憶體裡","的 Key-Value 資料庫。它快的原因有三個：",[66,28916,28917,28923,28932],{},[69,28918,28919,28922],{},[23,28920,28921],{},"資料在記憶體","：讀寫不用碰磁碟，單一指令通常在微秒等級完成",[69,28924,28925,28928,28929],{},[23,28926,28927],{},"單執行緒執行指令","：所有指令依序一個一個執行，沒有鎖的競爭。Redis 6.0 之後網路 I\u002FO 可以多執行緒處理，但",[23,28930,28931],{},"執行指令的仍然是單一執行緒",[69,28933,28934,28937],{},[23,28935,28936],{},"資料結構內建在伺服器端","：要對一個集合加一個元素，不用「讀出整個集合 → 改 → 寫回」，直接下一個指令就好",[11,28939,28940,28941,114,28944,28947],{},"第二點帶來一個非常重要的特性：",[23,28942,28943],{},"每一個指令都是原子的",[15,28945,28946],{},"INCR"," 一個計數器，就算一千個請求同時打進來，也不會有兩個請求讀到同一個值、互相覆蓋。這是很多應用情境的基礎。",[11,28949,28950,28951,28954],{},"但它也代表另一件事：",[23,28952,28953],{},"一個很慢的指令，會卡住所有其他的指令","。後面的注意事項大多都和這點有關。",[28,28956,28957],{"id":28957},"五種主要的資料結構",[11,28959,28960],{},[1952,28961],{"alt":28962,"src":28963},"Redis 五種資料結構示意圖：String 是單一值，例如 house:1024:views 為 3582，用 INCR 計數；Hash 是欄位與值的集合，例如 cart:u77 內有物件與數量；List 是有序串列，例如 recent:u77 用 LPUSH 加 LTRIM 保留最近 10 筆瀏覽；Set 是不重複集合，例如 fav:u77 收藏的物件，可用 SINTER 取交集；Sorted Set 是依分數排序的集合，例如 hot:houses:2023-02-21 以瀏覽數為分數，ZREVRANGE 取前 10 名做排行榜","\u002Fimages\u002Farticles\u002Fredis-data-structures.svg",[100,28965,28967],{"id":28966},"string最基本的值","String：最基本的值",[11,28969,28970],{},"一個 Key 對應一個值，值可以是字串、數字或序列化後的 JSON，最大 512MB（但實務上不該放這麼大）。",[132,28972,28973,28982],{},[135,28974,28975],{},[138,28976,28977,28980],{},[141,28978,28979],{},"指令",[141,28981,18185],{},[152,28983,28984,28994,29003,29017],{},[138,28985,28986,28991],{},[157,28987,28988],{},[15,28989,28990],{},"SET key value EX 300",[157,28992,28993],{},"寫入並設定 300 秒後過期",[138,28995,28996,29001],{},[157,28997,28998],{},[15,28999,29000],{},"GET key",[157,29002,12440],{},[138,29004,29005,29014],{},[157,29006,29007,29010,29011],{},[15,29008,29009],{},"INCR key","／",[15,29012,29013],{},"INCRBY key 5",[157,29015,29016],{},"原子地加一、加五",[138,29018,29019,29024],{},[157,29020,29021],{},[15,29022,29023],{},"SET key value NX",[157,29025,29026,29027,29029],{},"只有 Key ",[23,29028,9052],{},"時才寫入",[11,29031,29032,55],{},[23,29033,24605],{},[66,29035,29036,29046,29054,29063],{},[69,29037,29038,29041,29042,29045],{},[23,29039,29040],{},"物件瀏覽次數","：每次瀏覽 ",[15,29043,29044],{},"INCR house:1024:views","，不用每次都更新資料庫，定時再把數字寫回去",[69,29047,29048,29050,29051,29053],{},[23,29049,13671],{},"：把查詢結果序列化後 ",[15,29052,9846],{},"，設定過期時間",[69,29055,29056,55,29059,29062],{},[23,29057,29058],{},"限流",[15,29060,29061],{},"INCR rate:{ip}:{分鐘}","，第一次時設定 60 秒過期，數字超過上限就拒絕",[69,29064,29065,55,29068,29071],{},[23,29066,29067],{},"防止重複送出",[15,29069,29070],{},"SET submit:{訂單號} 1 NX EX 10","，回傳失敗就代表 10 秒內已經送過了",[100,29073,29075],{"id":29074},"hash一個物件的多個欄位","Hash：一個物件的多個欄位",[11,29077,29078],{},"一個 Key 底下有多個「欄位 → 值」，很像一個小型的物件或字典。",[132,29080,29081,29089],{},[135,29082,29083],{},[138,29084,29085,29087],{},[141,29086,28979],{},[141,29088,18185],{},[152,29090,29091,29101,29111,29121],{},[138,29092,29093,29098],{},[157,29094,29095],{},[15,29096,29097],{},"HSET key field value",[157,29099,29100],{},"設定一個欄位",[138,29102,29103,29108],{},[157,29104,29105],{},[15,29106,29107],{},"HGET key field",[157,29109,29110],{},"讀一個欄位",[138,29112,29113,29118],{},[157,29114,29115],{},[15,29116,29117],{},"HINCRBY key field 1",[157,29119,29120],{},"對一個欄位原子地加減",[138,29122,29123,29128],{},[157,29124,29125],{},[15,29126,29127],{},"HGETALL key",[157,29129,29130],{},"讀出全部欄位",[11,29132,29133,55],{},[23,29134,24605],{},[66,29136,29137,29150],{},[69,29138,29139,55,29142,29145,29146,29149],{},[23,29140,29141],{},"購物車",[15,29143,29144],{},"cart:{使用者}"," 底下，欄位是商品 ID、值是數量。加一件商品就是 ",[15,29147,29148],{},"HINCRBY cart:u77 sku-1 1","，不用把整台購物車讀出來改",[69,29151,29152,29155],{},[23,29153,29154],{},"物件的部分欄位常常變動","：例如物件的瀏覽數、收藏數、狀態各自是一個欄位，各自更新，不必每次都重新序列化整個物件",[11,29157,29158,29159,29162],{},"和「把整個物件序列化成 JSON 存進 String」相比，Hash 的優勢是",[23,29160,29161],{},"可以只讀、只改其中一個欄位","；缺點是巢狀結構不好表達，而且不能對單一欄位設定過期時間。",[100,29164,29166],{"id":29165},"list有順序的串列","List：有順序的串列",[11,29168,29169],{},"可以從頭或尾加入、取出的串列。",[132,29171,29172,29180],{},[135,29173,29174],{},[138,29175,29176,29178],{},[141,29177,28979],{},[141,29179,18185],{},[152,29181,29182,29192,29202,29212],{},[138,29183,29184,29189],{},[157,29185,29186],{},[15,29187,29188],{},"LPUSH key value",[157,29190,29191],{},"從左邊（頭）加入",[138,29193,29194,29199],{},[157,29195,29196],{},[15,29197,29198],{},"LTRIM key 0 9",[157,29200,29201],{},"只保留第 0～9 筆",[138,29203,29204,29209],{},[157,29205,29206],{},[15,29207,29208],{},"LRANGE key 0 9",[157,29210,29211],{},"讀取第 0～9 筆",[138,29213,29214,29219],{},[157,29215,29216],{},[15,29217,29218],{},"BRPOP key timeout",[157,29220,29221],{},"從右邊取出，沒有資料就等待",[11,29223,29224,55],{},[23,29225,24605],{},[66,29227,29228,29240],{},[69,29229,29230,29041,29233,9069,29236,29239],{},[23,29231,29232],{},"最近瀏覽的 10 個物件",[15,29234,29235],{},"LPUSH recent:u77 1024",[15,29237,29238],{},"LTRIM recent:u77 0 9","，串列永遠只有最新的 10 筆",[69,29241,29242,29245,29246,29249,29250,29253],{},[23,29243,29244],{},"簡單的工作佇列","：生產者 ",[15,29247,29248],{},"LPUSH","、消費者 ",[15,29251,29252],{},"BRPOP","。但取出後處理到一半當掉，這筆工作就沒了，也沒有重試、死信這些機制，重要的工作還是交給真正的訊息佇列",[100,29255,29257],{"id":29256},"set不重複的集合","Set：不重複的集合",[11,29259,29260],{},"元素不重複、沒有順序的集合，而且可以做交集、聯集、差集。",[132,29262,29263,29271],{},[135,29264,29265],{},[138,29266,29267,29269],{},[141,29268,28979],{},[141,29270,18185],{},[152,29272,29273,29283,29293,29303],{},[138,29274,29275,29280],{},[157,29276,29277],{},[15,29278,29279],{},"SADD key member",[157,29281,29282],{},"加入（已存在就忽略）",[138,29284,29285,29290],{},[157,29286,29287],{},[15,29288,29289],{},"SISMEMBER key member",[157,29291,29292],{},"判斷是否存在",[138,29294,29295,29300],{},[157,29296,29297],{},[15,29298,29299],{},"SINTER key1 key2",[157,29301,29302],{},"交集",[138,29304,29305,29310],{},[157,29306,29307],{},[15,29308,29309],{},"SCARD key",[157,29311,29312],{},"元素數量",[11,29314,29315,55],{},[23,29316,24605],{},[66,29318,29319,29332,29341],{},[69,29320,29321,55,29324,29327,29328,29331],{},[23,29322,29323],{},"收藏清單",[15,29325,29326],{},"SADD fav:u77 1024","，重複按收藏也不會出現兩筆；",[15,29329,29330],{},"SISMEMBER"," 判斷這個物件要不要顯示「已收藏」",[69,29333,29334,55,29337,29340],{},[23,29335,29336],{},"共同關注",[15,29338,29339],{},"SINTER fav:u77 fav:u88","，兩個人都收藏的物件",[69,29342,29343,55,29346,29349],{},[23,29344,29345],{},"標籤",[15,29347,29348],{},"tag:近捷運"," 底下放所有符合的物件 ID，多個條件就取交集",[100,29351,29353],{"id":29352},"sorted-set依分數排序的集合","Sorted Set：依分數排序的集合",[11,29355,29356,29357,29360],{},"每個元素多了一個",[23,29358,29359],{},"分數（Score）","，Redis 會一直依分數維持排序。這是 Redis 最有特色的結構。",[132,29362,29363,29371],{},[135,29364,29365],{},[138,29366,29367,29369],{},[141,29368,28979],{},[141,29370,18185],{},[152,29372,29373,29383,29393,29406],{},[138,29374,29375,29380],{},[157,29376,29377],{},[15,29378,29379],{},"ZINCRBY key 1 member",[157,29381,29382],{},"把某個元素的分數加一",[138,29384,29385,29390],{},[157,29386,29387],{},[15,29388,29389],{},"ZREVRANGE key 0 9 WITHSCORES",[157,29391,29392],{},"分數由高到低取前 10 名",[138,29394,29395,29403],{},[157,29396,29397,29010,29400],{},[15,29398,29399],{},"ZRANK",[15,29401,29402],{},"ZREVRANK key member",[157,29404,29405],{},"某個元素的名次",[138,29407,29408,29413],{},[157,29409,29410],{},[15,29411,29412],{},"ZRANGEBYSCORE key min max",[157,29414,29415],{},"取分數在某個範圍內的元素",[11,29417,29418,55],{},[23,29419,24605],{},[66,29421,29422,29440,29450],{},[69,29423,29424,29041,29427,29430,29431,29434,29435,19806,29438],{},[23,29425,29426],{},"熱門排行榜",[15,29428,29429],{},"ZINCRBY hot:houses:2023-02-21 1 1024","，首頁直接 ",[15,29432,29433],{},"ZREVRANGE"," 取前 10 名，不用每次對資料庫做 ",[15,29436,29437],{},"GROUP BY",[15,29439,26565],{},[69,29441,29442,29445,29446,29449],{},[23,29443,29444],{},"延遲任務","：分數放「要執行的時間戳」，排程每秒 ",[15,29447,29448],{},"ZRANGEBYSCORE key 0 {現在}"," 取出到期的任務",[69,29451,29452,29455],{},[23,29453,29454],{},"滑動視窗限流","：分數放請求的時間戳，每次先刪掉一分鐘以前的，再數剩下幾筆",[100,29457,29458],{"id":29458},"其他結構",[66,29460,29461,29467,29473,29479],{},[69,29462,29463,29466],{},[23,29464,29465],{},"Bitmap","：用一個 bit 代表一個狀態，例如每日簽到，一個使用者一年只要 365 個 bit",[69,29468,29469,29472],{},[23,29470,29471],{},"HyperLogLog","：用約 12KB 估算「不重複的數量」，例如每日不重複訪客數，有約 0.81% 的誤差",[69,29474,29475,29478],{},[23,29476,29477],{},"Geo","：存經緯度，查詢某個點方圓幾公里內的成員",[69,29480,29481,29483],{},[23,29482,21910],{},"：5.0 加入的訊息串流，支援 Consumer Group 與 Ack，比 List 更適合做訊息佇列",[28,29485,29487],{"id":29486},"使用情境房仲網站的物件頁","使用情境：房仲網站的物件頁",[11,29489,29490],{},"一個物件頁面打開時，背後可以這樣用 Redis：",[36,29492,29494],{"className":3145,"code":29493,"language":3147,"meta":44,"style":44},"var db = redis.GetDatabase();\nvar houseId = 1024;\nvar userId = \"u77\";\nvar today = DateTime.Today.ToString(\"yyyy-MM-dd\");\n\n\u002F\u002F 1. 瀏覽次數 +1（String）\nawait db.StringIncrementAsync($\"house:{houseId}:views\");\n\n\u002F\u002F 2. 今日熱門排行 +1，並讓排行榜兩天後自動過期（Sorted Set）\nvar hotKey = $\"hot:houses:{today}\";\nawait db.SortedSetIncrementAsync(hotKey, houseId, 1);\nawait db.KeyExpireAsync(hotKey, TimeSpan.FromDays(2));\n\n\u002F\u002F 3. 加入最近瀏覽，只保留 10 筆（List）\nvar recentKey = $\"recent:{userId}\";\nawait db.ListLeftPushAsync(recentKey, houseId);\nawait db.ListTrimAsync(recentKey, 0, 9);\n\n\u002F\u002F 4. 這個物件是否已收藏（Set）\nvar isFavorite = await db.SetContainsAsync($\"fav:{userId}\", houseId);\n\n\u002F\u002F 首頁：今日熱門前 10 名\nvar top10 = await db.SortedSetRangeByRankWithScoresAsync(hotKey, 0, 9, Order.Descending);\n",[15,29495,29496,29511,29525,29538,29557,29561,29566,29592,29596,29601,29624,29640,29661,29665,29670,29693,29705,29726,29730,29735,29767,29771,29776],{"__ignoreMap":44},[376,29497,29498,29500,29503,29505,29507,29509],{"class":378,"line":379},[376,29499,4684],{"class":3196},[376,29501,29502],{"class":1020}," db",[376,29504,3313],{"class":3196},[376,29506,3520],{"class":393},[376,29508,3523],{"class":1020},[376,29510,3532],{"class":393},[376,29512,29513,29515,29518,29520,29523],{"class":378,"line":386},[376,29514,4684],{"class":3196},[376,29516,29517],{"class":1020}," houseId",[376,29519,3313],{"class":3196},[376,29521,29522],{"class":560}," 1024",[376,29524,3428],{"class":393},[376,29526,29527,29529,29531,29533,29536],{"class":378,"line":397},[376,29528,4684],{"class":3196},[376,29530,26152],{"class":1020},[376,29532,3313],{"class":3196},[376,29534,29535],{"class":414}," \"u77\"",[376,29537,3428],{"class":393},[376,29539,29540,29542,29545,29547,29549,29551,29553,29555],{"class":378,"line":405},[376,29541,4684],{"class":3196},[376,29543,29544],{"class":1020}," today",[376,29546,3313],{"class":3196},[376,29548,9683],{"class":393},[376,29550,9349],{"class":1020},[376,29552,3220],{"class":393},[376,29554,9690],{"class":414},[376,29556,3376],{"class":393},[376,29558,29559],{"class":378,"line":418},[376,29560,1036],{"emptyLinePlaceholder":1035},[376,29562,29563],{"class":378,"line":429},[376,29564,29565],{"class":382},"\u002F\u002F 1. 瀏覽次數 +1（String）\n",[376,29567,29568,29570,29573,29576,29578,29580,29582,29585,29587,29590],{"class":378,"line":437},[376,29569,9624],{"class":3196},[376,29571,29572],{"class":393}," db.",[376,29574,29575],{"class":1020},"StringIncrementAsync",[376,29577,3220],{"class":393},[376,29579,12729],{"class":414},[376,29581,4729],{"class":414},[376,29583,29584],{"class":393},"houseId",[376,29586,4741],{"class":414},[376,29588,29589],{"class":414},":views\"",[376,29591,3376],{"class":393},[376,29593,29594],{"class":378,"line":447},[376,29595,1036],{"emptyLinePlaceholder":1035},[376,29597,29598],{"class":378,"line":531},[376,29599,29600],{"class":382},"\u002F\u002F 2. 今日熱門排行 +1，並讓排行榜兩天後自動過期（Sorted Set）\n",[376,29602,29603,29605,29608,29610,29613,29615,29618,29620,29622],{"class":378,"line":542},[376,29604,4684],{"class":3196},[376,29606,29607],{"class":1020}," hotKey",[376,29609,3313],{"class":3196},[376,29611,29612],{"class":414}," $\"hot:houses:",[376,29614,4729],{"class":414},[376,29616,29617],{"class":393},"today",[376,29619,4741],{"class":414},[376,29621,4744],{"class":414},[376,29623,3428],{"class":393},[376,29625,29626,29628,29630,29633,29636,29638],{"class":378,"line":552},[376,29627,9624],{"class":3196},[376,29629,29572],{"class":393},[376,29631,29632],{"class":1020},"SortedSetIncrementAsync",[376,29634,29635],{"class":393},"(hotKey, houseId, ",[376,29637,892],{"class":560},[376,29639,3376],{"class":393},[376,29641,29642,29644,29646,29649,29652,29655,29657,29659],{"class":378,"line":564},[376,29643,9624],{"class":3196},[376,29645,29572],{"class":393},[376,29647,29648],{"class":1020},"KeyExpireAsync",[376,29650,29651],{"class":393},"(hotKey, TimeSpan.",[376,29653,29654],{"class":1020},"FromDays",[376,29656,3220],{"class":393},[376,29658,347],{"class":560},[376,29660,3839],{"class":393},[376,29662,29663],{"class":378,"line":575},[376,29664,1036],{"emptyLinePlaceholder":1035},[376,29666,29667],{"class":378,"line":583},[376,29668,29669],{"class":382},"\u002F\u002F 3. 加入最近瀏覽，只保留 10 筆（List）\n",[376,29671,29672,29674,29677,29679,29682,29684,29687,29689,29691],{"class":378,"line":597},[376,29673,4684],{"class":3196},[376,29675,29676],{"class":1020}," recentKey",[376,29678,3313],{"class":3196},[376,29680,29681],{"class":414}," $\"recent:",[376,29683,4729],{"class":414},[376,29685,29686],{"class":393},"userId",[376,29688,4741],{"class":414},[376,29690,4744],{"class":414},[376,29692,3428],{"class":393},[376,29694,29695,29697,29699,29702],{"class":378,"line":605},[376,29696,9624],{"class":3196},[376,29698,29572],{"class":393},[376,29700,29701],{"class":1020},"ListLeftPushAsync",[376,29703,29704],{"class":393},"(recentKey, houseId);\n",[376,29706,29707,29709,29711,29714,29717,29719,29721,29724],{"class":378,"line":616},[376,29708,9624],{"class":3196},[376,29710,29572],{"class":393},[376,29712,29713],{"class":1020},"ListTrimAsync",[376,29715,29716],{"class":393},"(recentKey, ",[376,29718,4975],{"class":560},[376,29720,3188],{"class":393},[376,29722,29723],{"class":560},"9",[376,29725,3376],{"class":393},[376,29727,29728],{"class":378,"line":624},[376,29729,1036],{"emptyLinePlaceholder":1035},[376,29731,29732],{"class":378,"line":635},[376,29733,29734],{"class":382},"\u002F\u002F 4. 這個物件是否已收藏（Set）\n",[376,29736,29737,29739,29742,29744,29746,29748,29751,29753,29756,29758,29760,29762,29764],{"class":378,"line":5815},[376,29738,4684],{"class":3196},[376,29740,29741],{"class":1020}," isFavorite",[376,29743,3313],{"class":3196},[376,29745,9704],{"class":3196},[376,29747,29572],{"class":393},[376,29749,29750],{"class":1020},"SetContainsAsync",[376,29752,3220],{"class":393},[376,29754,29755],{"class":414},"$\"fav:",[376,29757,4729],{"class":414},[376,29759,29686],{"class":393},[376,29761,4741],{"class":414},[376,29763,4744],{"class":414},[376,29765,29766],{"class":393},", houseId);\n",[376,29768,29769],{"class":378,"line":7233},[376,29770,1036],{"emptyLinePlaceholder":1035},[376,29772,29773],{"class":378,"line":7243},[376,29774,29775],{"class":382},"\u002F\u002F 首頁：今日熱門前 10 名\n",[376,29777,29778,29780,29783,29785,29787,29789,29792,29795,29797,29799,29801],{"class":378,"line":7251},[376,29779,4684],{"class":3196},[376,29781,29782],{"class":1020}," top10",[376,29784,3313],{"class":3196},[376,29786,9704],{"class":3196},[376,29788,29572],{"class":393},[376,29790,29791],{"class":1020},"SortedSetRangeByRankWithScoresAsync",[376,29793,29794],{"class":393},"(hotKey, ",[376,29796,4975],{"class":560},[376,29798,3188],{"class":393},[376,29800,29723],{"class":560},[376,29802,29803],{"class":393},", Order.Descending);\n",[11,29805,29806,29807,2390,29810,29813],{},"上面 1～4 是四次網路往返。對延遲敏感時可以用 ",[15,29808,29809],{},"IBatch",[15,29811,29812],{},"CreateTransaction()"," 把多個指令一次送出；需要「多個指令中間不被插隊」時，用 Lua Script 讓它們在 Redis 內一次執行完。",[28,29815,29817],{"id":29816},"過期記憶體與持久化","過期、記憶體與持久化",[100,29819,29820],{"id":29820},"過期時間",[11,29822,9999,29823,2390,29825,29828,29829,29832],{},[15,29824,9072],{},[15,29826,29827],{},"SET ... EX"," 設定的 Key，到期後就會被刪除。Redis 用兩種方式清理：存取時發現過期就刪（惰性刪除），加上背景定期抽樣檢查。所以",[23,29830,29831],{},"過期的 Key 不一定馬上從記憶體消失","，但讀取時一定讀不到。",[100,29834,29835],{"id":29835},"記憶體滿了怎麼辦",[11,29837,29838,29841,29842,29845],{},[15,29839,29840],{},"maxmemory"," 設定 Redis 最多用多少記憶體，用滿之後依 ",[15,29843,29844],{},"maxmemory-policy"," 處理：",[132,29847,29848,29859],{},[135,29849,29850],{},[138,29851,29852,29854,29857],{},[141,29853,16510],{},[141,29855,29856],{},"行為",[141,29858,6174],{},[152,29860,29861,29877,29892,29909],{},[138,29862,29863,29868,29874],{},[157,29864,29865,6448],{},[15,29866,29867],{},"noeviction",[157,29869,29870,29871],{},"不刪任何東西，",[23,29872,29873],{},"寫入直接報錯",[157,29875,29876],{},"資料不能被刪的情境",[138,29878,29879,29884,29887],{},[157,29880,29881],{},[15,29882,29883],{},"allkeys-lru",[157,29885,29886],{},"從所有 Key 中刪除最久沒用的",[157,29888,29889],{},[23,29890,29891],{},"純快取",[138,29893,29894,29899,29906],{},[157,29895,29896],{},[15,29897,29898],{},"volatile-lru",[157,29900,29901,29902,29905],{},"只從",[23,29903,29904],{},"有設過期時間","的 Key 中刪最久沒用的",[157,29907,29908],{},"快取與重要資料混放",[138,29910,29911,29919,29922],{},[157,29912,29913,29010,29916],{},[15,29914,29915],{},"allkeys-lfu",[15,29917,29918],{},"volatile-lfu",[157,29920,29921],{},"依使用頻率刪除",[157,29923,29924],{},"有明顯熱點的快取",[11,29926,29927,29928,29930,29931,29933],{},"純粹當快取用的 Redis，記得改成 ",[15,29929,29883],{},"；用預設的 ",[15,29932,29867],{},"，記憶體滿的那一刻，所有寫入都會失敗。",[100,29935,29936],{"id":29936},"持久化",[11,29938,29939],{},"Redis 重啟時，記憶體的資料要從磁碟載回來，有兩種方式：",[66,29941,29942,29951],{},[69,29943,29944,29947,29948],{},[23,29945,29946],{},"RDB","：定期把整個記憶體快照存成一個檔案。檔案小、重啟快，但",[23,29949,29950],{},"兩次快照之間的資料會遺失",[69,29952,29953,29956,29957,29960],{},[23,29954,29955],{},"AOF","：把每個寫入指令記錄下來，",[15,29958,29959],{},"appendfsync everysec"," 時最多遺失 1 秒的資料，檔案比較大，需要定期重寫壓縮",[11,29962,29963],{},"兩者可以同時開啟。如果 Redis 只是快取，掉了可以從資料庫重建，RDB 就夠了；如果裡面有不能遺失的資料（例如計數器還沒寫回資料庫），就要開 AOF。",[28,29965,29966],{"id":29966},"上線前要知道的事",[66,29968,29969,29982,30002,30008,30021,30027,30033],{},[69,29970,29971,29977,29978,29981],{},[23,29972,29973,29974],{},"不要在正式環境下 ",[15,29975,29976],{},"KEYS *","：它會掃過所有的 Key，幾百萬個 Key 時要好幾秒，這段時間 Redis 完全無法處理其他請求，所有服務一起逾時。要找 Key 用 ",[15,29979,29980],{},"SCAN","，它每次只掃一小部分",[69,29983,29984,29987,29988,2276,29991,2276,29994,29997,29998,30001],{},[23,29985,29986],{},"小心 Big Key","：一個 Hash 裡有幾百萬個欄位、一個 List 有幾十萬筆，",[15,29989,29990],{},"HGETALL",[15,29992,29993],{},"LRANGE 0 -1",[15,29995,29996],{},"SMEMBERS"," 這種讀取全部的指令會很慢，刪除它也會卡住（改用 ",[15,29999,30000],{},"UNLINK"," 在背景刪除）。資料量大的集合要拆成多個 Key",[69,30003,30004,30007],{},[23,30005,30006],{},"小心 Hot Key","：所有請求都打同一個 Key，例如首頁的熱門排行榜，在 Cluster 模式下會集中在同一個節點上。可以在應用程式加一層短時間的本機快取",[69,30009,30010,17687,30013,30016,30017,30020],{},[23,30011,30012],{},"Key 要有命名規則",[15,30014,30015],{},":"," 分層，例如 ",[15,30018,30019],{},"{服務}:{實體}:{ID}:{用途}","，在監控工具裡才找得到、才知道是誰放的",[69,30022,30023,30026],{},[23,30024,30025],{},"快取的 Key 一定要設過期時間","：沒有過期時間的 Key 只會越來越多，直到記憶體用滿",[69,30028,30029,30032],{},[23,30030,30031],{},"不要把 Redis 當成唯一的資料來源","：即使開了 AOF，Redis 也不是設計來取代關聯式資料庫的。重要的資料最終還是要落地到資料庫",[69,30034,30035,30038,30039,30042,30043,30046],{},[23,30036,30037],{},"連線要共用","：StackExchange.Redis 的 ",[15,30040,30041],{},"ConnectionMultiplexer"," 設計上就是",[23,30044,30045],{},"整個應用程式共用一個","，註冊成 Singleton，不要每次請求都建立新連線",[28,30048,1890],{"id":1890},[11,30050,30051,30052,30055],{},"Redis 是記憶體中的資料結構伺服器：String 做計數與快取、Hash 存物件欄位、List 做最近 N 筆、Set 做不重複與交集、Sorted Set 做排行與延遲任務，每個指令都是原子的；但也因為單執行緒，",[15,30053,30054],{},"KEYS","、Big Key 這種慢指令會拖垮所有請求，上線前要設定好過期時間、淘汰策略與持久化方式。",[1132,30057,21692],{},{"title":44,"searchDepth":386,"depth":386,"links":30059},[30060,30061,30069,30070,30075,30076],{"id":28906,"depth":386,"text":28907},{"id":28957,"depth":386,"text":28957,"children":30062},[30063,30064,30065,30066,30067,30068],{"id":28966,"depth":397,"text":28967},{"id":29074,"depth":397,"text":29075},{"id":29165,"depth":397,"text":29166},{"id":29256,"depth":397,"text":29257},{"id":29352,"depth":397,"text":29353},{"id":29458,"depth":397,"text":29458},{"id":29486,"depth":386,"text":29487},{"id":29816,"depth":386,"text":29817,"children":30071},[30072,30073,30074],{"id":29820,"depth":397,"text":29820},{"id":29835,"depth":397,"text":29835},{"id":29936,"depth":397,"text":29936},{"id":29966,"depth":386,"text":29966},{"id":1890,"depth":386,"text":1890},"2023-02-21",{},{"title":28901,"description":44},"articles\u002Fredis-data-structures-use-cases",[10102,13671,30082,3039],"資料結構","76avnB3SkQPDQM3-21hDsuni5l01ya4In--WAsSvPmQ",{"id":30085,"title":30086,"body":30087,"date":30957,"description":44,"excerpt":1156,"extension":1157,"meta":30958,"navigation":1035,"path":3919,"seo":30959,"stem":30960,"tags":30961,"__hash__":30962},"articles\u002Farticles\u002Frabbitmq-ack-prefetch-dead-letter.md","RabbitMQ 訊息怎麼才不會掉？Ack、Prefetch、延遲重試與死信佇列",{"type":8,"value":30088,"toc":30943},[30089,30093,30099,30148,30152,30164,30229,30239,30243,30249,30272,30372,30375,30379,30383,30395,30399,30410,30552,30562,30566,30577,30617,30637,30641,30652,30655,30669,30675,30678,30684,30691,30726,30879,30892,30898,30902,30909,30935,30937,30940],[28,30090,30092],{"id":30091},"訊息可能在哪裡掉","訊息可能在哪裡掉？",[11,30094,30095,30096,30098],{},"Exchange、Queue、Binding 的基本關係可以先參考 ",[119,30097,3916],{"href":3915}," 這篇。一則訊息從發出到處理完，會經過三段，每一段都可能出事：",[132,30100,30101,30113],{},[135,30102,30103],{},[138,30104,30105,30107,30110],{},[141,30106,23047],{},[141,30108,30109],{},"可能發生的事",[141,30111,30112],{},"對應的機制",[152,30114,30115,30126,30137],{},[138,30116,30117,30120,30123],{},[157,30118,30119],{},"Producer → Broker",[157,30121,30122],{},"網路中斷，訊息根本沒送到",[157,30124,30125],{},"Publisher Confirm",[138,30127,30128,30131,30134],{},[157,30129,30130],{},"存放在 Broker",[157,30132,30133],{},"Broker 重啟，記憶體中的訊息消失",[157,30135,30136],{},"Durable Exchange／Queue、Persistent Message",[138,30138,30139,30142,30145],{},[157,30140,30141],{},"Broker → Consumer",[157,30143,30144],{},"Consumer 拿到訊息後當掉、處理失敗",[157,30146,30147],{},"手動 Ack、重試、死信佇列",[28,30149,30151],{"id":30150},"第一段確定訊息真的送到了","第一段：確定訊息真的送到了",[11,30153,30154,30157,30158,30161,30162,55],{},[15,30155,30156],{},"BasicPublish"," 呼叫成功，只代表訊息被寫進了 TCP 連線，",[23,30159,30160],{},"不代表 Broker 收到了","。要確認 Broker 真的接手，需要開啟 ",[23,30163,30125],{},[36,30165,30167],{"className":3145,"code":30166,"language":3147,"meta":44,"style":44},"channel.ConfirmSelect();\n\nchannel.BasicPublish(\"house.events\", \"house.created.franchise\", props, body);\n\n\u002F\u002F 等待 Broker 確認；逾時或被拒絕會拋出例外，由呼叫端決定重送或記錄\nchannel.WaitForConfirmsOrDie(TimeSpan.FromSeconds(5));\n",[15,30168,30169,30179,30183,30202,30206,30211],{"__ignoreMap":44},[376,30170,30171,30174,30177],{"class":378,"line":379},[376,30172,30173],{"class":393},"channel.",[376,30175,30176],{"class":1020},"ConfirmSelect",[376,30178,3532],{"class":393},[376,30180,30181],{"class":378,"line":386},[376,30182,1036],{"emptyLinePlaceholder":1035},[376,30184,30185,30187,30189,30191,30194,30196,30199],{"class":378,"line":397},[376,30186,30173],{"class":393},[376,30188,30156],{"class":1020},[376,30190,3220],{"class":393},[376,30192,30193],{"class":414},"\"house.events\"",[376,30195,3188],{"class":393},[376,30197,30198],{"class":414},"\"house.created.franchise\"",[376,30200,30201],{"class":393},", props, body);\n",[376,30203,30204],{"class":378,"line":405},[376,30205,1036],{"emptyLinePlaceholder":1035},[376,30207,30208],{"class":378,"line":418},[376,30209,30210],{"class":382},"\u002F\u002F 等待 Broker 確認；逾時或被拒絕會拋出例外，由呼叫端決定重送或記錄\n",[376,30212,30213,30215,30218,30220,30222,30224,30227],{"class":378,"line":429},[376,30214,30173],{"class":393},[376,30216,30217],{"class":1020},"WaitForConfirmsOrDie",[376,30219,3829],{"class":393},[376,30221,3832],{"class":1020},[376,30223,3220],{"class":393},[376,30225,30226],{"class":560},"5",[376,30228,3839],{"class":393},[11,30230,30231,30232,29010,30235,30238],{},"每發一筆就同步等待確認，吞吐量會下降很多。訊息量大時，可以改成批次發送後再一起等待確認，或是監聽 ",[15,30233,30234],{},"BasicAcks",[15,30236,30237],{},"BasicNacks"," 事件做非同步確認。",[28,30240,30242],{"id":30241},"第二段broker-重啟也不能掉","第二段：Broker 重啟也不能掉",[11,30244,30245,30246,55],{},"RabbitMQ 預設把訊息放在記憶體裡。要讓訊息在 Broker 重啟後還在，需要",[23,30247,30248],{},"三個條件同時成立",[66,30250,30251,30257,30263],{},[69,30252,30253,30256],{},[23,30254,30255],{},"Exchange 是 Durable","：重啟後 Exchange 本身還在",[69,30258,30259,30262],{},[23,30260,30261],{},"Queue 是 Durable","：重啟後 Queue 本身還在",[69,30264,30265,6342,30268,30271],{},[23,30266,30267],{},"訊息是 Persistent",[15,30269,30270],{},"DeliveryMode = 2","）：訊息會寫到磁碟",[36,30273,30275],{"className":3145,"code":30274,"language":3147,"meta":44,"style":44},"channel.ExchangeDeclare(\"house.events\", ExchangeType.Topic, durable: true);\nchannel.QueueDeclare(\"franchise-sync\", durable: true, exclusive: false, autoDelete: false);\n\nvar props = channel.CreateBasicProperties();\nprops.Persistent = true;\n",[15,30276,30277,30300,30340,30344,30361],{"__ignoreMap":44},[376,30278,30279,30281,30284,30286,30288,30291,30294,30296,30298],{"class":378,"line":379},[376,30280,30173],{"class":393},[376,30282,30283],{"class":1020},"ExchangeDeclare",[376,30285,3220],{"class":393},[376,30287,30193],{"class":414},[376,30289,30290],{"class":393},", ExchangeType.Topic, ",[376,30292,30293],{"class":1020},"durable",[376,30295,411],{"class":393},[376,30297,18747],{"class":560},[376,30299,3376],{"class":393},[376,30301,30302,30304,30307,30309,30312,30314,30316,30318,30320,30322,30325,30327,30329,30331,30334,30336,30338],{"class":378,"line":386},[376,30303,30173],{"class":393},[376,30305,30306],{"class":1020},"QueueDeclare",[376,30308,3220],{"class":393},[376,30310,30311],{"class":414},"\"franchise-sync\"",[376,30313,3188],{"class":393},[376,30315,30293],{"class":1020},[376,30317,411],{"class":393},[376,30319,18747],{"class":560},[376,30321,3188],{"class":393},[376,30323,30324],{"class":1020},"exclusive",[376,30326,411],{"class":393},[376,30328,5575],{"class":560},[376,30330,3188],{"class":393},[376,30332,30333],{"class":1020},"autoDelete",[376,30335,411],{"class":393},[376,30337,5575],{"class":560},[376,30339,3376],{"class":393},[376,30341,30342],{"class":378,"line":397},[376,30343,1036],{"emptyLinePlaceholder":1035},[376,30345,30346,30348,30351,30353,30356,30359],{"class":378,"line":405},[376,30347,4684],{"class":3196},[376,30349,30350],{"class":1020}," props",[376,30352,3313],{"class":3196},[376,30354,30355],{"class":393}," channel.",[376,30357,30358],{"class":1020},"CreateBasicProperties",[376,30360,3532],{"class":393},[376,30362,30363,30366,30368,30370],{"class":378,"line":418},[376,30364,30365],{"class":393},"props.Persistent ",[376,30367,3240],{"class":3196},[376,30369,3802],{"class":560},[376,30371,3428],{"class":393},[11,30373,30374],{},"只設定了 Durable Queue、卻忘了把訊息設成 Persistent，是最常見的漏洞：Queue 重啟後還在，但裡面是空的。",[28,30376,30378],{"id":30377},"第三段consumer-端的-ack","第三段：Consumer 端的 Ack",[100,30380,30382],{"id":30381},"自動-ack-的問題","自動 Ack 的問題",[11,30384,30385,17363,30388,17065,30391,30394],{},[15,30386,30387],{},"BasicConsume",[15,30389,30390],{},"autoAck: true",[23,30392,30393],{},"訊息一送出給 Consumer，Broker 就把它刪掉","。如果 Consumer 拿到訊息後、處理完成前當掉了，這則訊息就永遠消失了。",[100,30396,30398],{"id":30397},"手動-ack","手動 Ack",[11,30400,30401,30402,30405,30406,30409],{},"改成 ",[15,30403,30404],{},"autoAck: false","，處理成功之後才回覆 ",[15,30407,30408],{},"BasicAck","。Consumer 如果在 Ack 之前斷線，Broker 會把這則訊息重新放回佇列，交給其他 Consumer：",[36,30411,30413],{"className":3145,"code":30412,"language":3147,"meta":44,"style":44},"var consumer = new EventingBasicConsumer(channel);\nconsumer.Received += (_, ea) =>\n{\n    try\n    {\n        Handle(ea.Body.ToArray());\n        channel.BasicAck(ea.DeliveryTag, multiple: false);\n    }\n    catch (Exception ex)\n    {\n        \u002F\u002F 失敗怎麼處理？見下一節\n    }\n};\nchannel.BasicConsume(\"franchise-sync\", autoAck: false, consumer);\n",[15,30414,30415,30432,30453,30457,30461,30465,30477,30496,30500,30513,30517,30522,30526,30530],{"__ignoreMap":44},[376,30416,30417,30419,30422,30424,30426,30429],{"class":378,"line":379},[376,30418,4684],{"class":3196},[376,30420,30421],{"class":1020}," consumer",[376,30423,3313],{"class":3196},[376,30425,4692],{"class":3196},[376,30427,30428],{"class":1020}," EventingBasicConsumer",[376,30430,30431],{"class":393},"(channel);\n",[376,30433,30434,30437,30440,30442,30444,30446,30449,30451],{"class":378,"line":386},[376,30435,30436],{"class":393},"consumer.Received ",[376,30438,30439],{"class":3196},"+=",[376,30441,5046],{"class":393},[376,30443,4994],{"class":1020},[376,30445,3188],{"class":393},[376,30447,30448],{"class":1020},"ea",[376,30450,5662],{"class":393},[376,30452,3778],{"class":3196},[376,30454,30455],{"class":378,"line":397},[376,30456,3324],{"class":393},[376,30458,30459],{"class":378,"line":405},[376,30460,5699],{"class":3196},[376,30462,30463],{"class":378,"line":418},[376,30464,5704],{"class":393},[376,30466,30467,30470,30473,30475],{"class":378,"line":429},[376,30468,30469],{"class":1020},"        Handle",[376,30471,30472],{"class":393},"(ea.Body.",[376,30474,5032],{"class":1020},[376,30476,12018],{"class":393},[376,30478,30479,30482,30484,30487,30490,30492,30494],{"class":378,"line":437},[376,30480,30481],{"class":393},"        channel.",[376,30483,30408],{"class":1020},[376,30485,30486],{"class":393},"(ea.DeliveryTag, ",[376,30488,30489],{"class":1020},"multiple",[376,30491,411],{"class":393},[376,30493,5575],{"class":560},[376,30495,3376],{"class":393},[376,30497,30498],{"class":378,"line":447},[376,30499,5766],{"class":393},[376,30501,30502,30504,30506,30508,30511],{"class":378,"line":531},[376,30503,5771],{"class":3196},[376,30505,5046],{"class":393},[376,30507,24241],{"class":1020},[376,30509,30510],{"class":1020}," ex",[376,30512,3319],{"class":393},[376,30514,30515],{"class":378,"line":542},[376,30516,5704],{"class":393},[376,30518,30519],{"class":378,"line":552},[376,30520,30521],{"class":382},"        \u002F\u002F 失敗怎麼處理？見下一節\n",[376,30523,30524],{"class":378,"line":564},[376,30525,5766],{"class":393},[376,30527,30528],{"class":378,"line":575},[376,30529,4766],{"class":393},[376,30531,30532,30534,30536,30538,30540,30542,30545,30547,30549],{"class":378,"line":583},[376,30533,30173],{"class":393},[376,30535,30387],{"class":1020},[376,30537,3220],{"class":393},[376,30539,30311],{"class":414},[376,30541,3188],{"class":393},[376,30543,30544],{"class":1020},"autoAck",[376,30546,411],{"class":393},[376,30548,5575],{"class":560},[376,30550,30551],{"class":393},", consumer);\n",[11,30553,30554,30555,30558,30559,30561],{},"手動 Ack 帶來一個必然的結果：",[23,30556,30557],{},"同一則訊息可能被處理不只一次","（例如處理完成、Ack 送出前剛好斷線）。所以 Consumer 的處理邏輯要設計成",[23,30560,6606],{},"的——例如寫入 Elasticsearch 時用物件 ID 當文件 ID 做 Upsert，處理兩次的結果和一次相同。",[100,30563,30565],{"id":30564},"prefetch一次拿幾則","Prefetch：一次拿幾則",[11,30567,30568,30569,30572,30573,30576],{},"Prefetch（",[15,30570,30571],{},"BasicQos","）限制「一個 Consumer 手上最多可以有幾則",[23,30574,30575],{},"還沒 Ack"," 的訊息」：",[36,30578,30580],{"className":3145,"code":30579,"language":3147,"meta":44,"style":44},"channel.BasicQos(prefetchSize: 0, prefetchCount: 10, global: false);\n",[15,30581,30582],{"__ignoreMap":44},[376,30583,30584,30586,30588,30590,30593,30595,30597,30599,30602,30604,30606,30608,30611,30613,30615],{"class":378,"line":379},[376,30585,30173],{"class":393},[376,30587,30571],{"class":1020},[376,30589,3220],{"class":393},[376,30591,30592],{"class":1020},"prefetchSize",[376,30594,411],{"class":393},[376,30596,4975],{"class":560},[376,30598,3188],{"class":393},[376,30600,30601],{"class":1020},"prefetchCount",[376,30603,411],{"class":393},[376,30605,361],{"class":560},[376,30607,3188],{"class":393},[376,30609,30610],{"class":1020},"global",[376,30612,411],{"class":393},[376,30614,5575],{"class":560},[376,30616,3376],{"class":393},[66,30618,30619,30625,30631],{},[69,30620,30621,30624],{},[23,30622,30623],{},"不設定（無上限）","：Broker 會把佇列裡的訊息盡可能全部推給第一個連上的 Consumer，後來加入的 Consumer 可能分不到工作；Consumer 的記憶體也可能被大量訊息塞爆",[69,30626,30627,30630],{},[23,30628,30629],{},"設太小（例如 1）","：每處理完一則才會拿到下一則，網路往返的時間會拖慢吞吐量",[69,30632,30633,30636],{},[23,30634,30635],{},"一般做法","：從 10～50 開始，依處理時間與 Consumer 數量調整。處理越慢的工作，Prefetch 越小，訊息才能平均分散到每個 Consumer",[28,30638,30640],{"id":30639},"失敗了怎麼辦不要無限-requeue","失敗了怎麼辦：不要無限 Requeue",[11,30642,30643,30644,30647,30648,30651],{},"處理失敗時，最直覺的做法是 ",[15,30645,30646],{},"BasicNack(requeue: true)"," 把訊息放回佇列。但如果失敗的原因是",[23,30649,30650],{},"資料本身有問題","（例如欄位格式錯誤），這則訊息會馬上又被拿出來、又失敗、又放回去，形成無限迴圈，CPU 飆高、日誌被洗版，後面的正常訊息也一直被它插隊。",[11,30653,30654],{},"失敗要分成兩種：",[66,30656,30657,30663],{},[69,30658,30659,30662],{},[23,30660,30661],{},"暫時性錯誤","：Elasticsearch 短暫無回應、網路抖動、資料庫鎖定逾時——等一下再試就會成功",[69,30664,30665,30668],{},[23,30666,30667],{},"永久性錯誤","：資料格式錯誤、找不到關聯資料、程式邏輯無法處理——試幾次都一樣",[11,30670,30671,30672,114],{},"對應的做法是",[23,30673,30674],{},"延遲重試 + 死信佇列",[28,30676,30677],{"id":30677},"延遲重試與死信佇列",[11,30679,30680],{},[1952,30681],{"alt":30682,"src":30683},"RabbitMQ 延遲重試與死信佇列流程：訊息從 house.events 進入 franchise-sync 主佇列，Consumer 寫入 Elasticsearch 成功就 Ack；失敗且重試次數小於 3 時，Ack 原訊息並把重試次數加一後發到 retry Exchange，進入沒有 Consumer、TTL 30 秒的 retry-queue，過期後透過死信機制回到主 Exchange 重新投遞；已重試 3 次或無法處理的資料則送到 dead-letter-queue，停止重試並告警後人工處理","\u002Fimages\u002Farticles\u002Frabbitmq-retry-dlq.svg",[11,30685,30686,30687,30690],{},"RabbitMQ 沒有內建「延遲 30 秒再投遞」的功能（除非安裝 Delayed Message 外掛），但可以用 ",[23,30688,30689],{},"TTL + 死信交換器（Dead Letter Exchange, DLX）"," 組出來：",[211,30692,30693,30707,30716,30719],{},[69,30694,30695,30696,30699,30700,30703,30704],{},"宣告一個 ",[15,30697,30698],{},"retry-queue","，設定訊息 TTL 為 30 秒，並把它的 DLX 指向",[23,30701,30702],{},"主 Exchange","。這個佇列",[23,30705,30706],{},"沒有任何 Consumer",[69,30708,30709,30710,30713,30714],{},"處理失敗時，Consumer 先 Ack 原訊息，再把它連同 ",[15,30711,30712],{},"x-retry-count + 1"," 的標頭發到 ",[15,30715,30698],{},[69,30717,30718],{},"30 秒後訊息過期，RabbitMQ 依 DLX 設定把它送回主 Exchange，重新進入主佇列再處理一次",[69,30720,30721,30722,30725],{},"重試次數超過上限（例如 3 次），或判斷是永久性錯誤，就直接送進 ",[15,30723,30724],{},"dead-letter-queue","，不再重試",[36,30727,30729],{"className":3145,"code":30728,"language":3147,"meta":44,"style":44},"\u002F\u002F 重試佇列：TTL 到期後死信回主 Exchange\nchannel.QueueDeclare(\"franchise-sync.retry\", durable: true, exclusive: false, autoDelete: false,\n    arguments: new Dictionary\u003Cstring, object>\n    {\n        [\"x-message-ttl\"] = 30_000,\n        [\"x-dead-letter-exchange\"] = \"house.events\"\n    });\n\n\u002F\u002F 死信佇列：停止重試，等人處理\nchannel.QueueDeclare(\"franchise-sync.dlq\", durable: true, exclusive: false, autoDelete: false);\n",[15,30730,30731,30736,30773,30795,30799,30815,30829,30833,30837,30842],{"__ignoreMap":44},[376,30732,30733],{"class":378,"line":379},[376,30734,30735],{"class":382},"\u002F\u002F 重試佇列：TTL 到期後死信回主 Exchange\n",[376,30737,30738,30740,30742,30744,30747,30749,30751,30753,30755,30757,30759,30761,30763,30765,30767,30769,30771],{"class":378,"line":386},[376,30739,30173],{"class":393},[376,30741,30306],{"class":1020},[376,30743,3220],{"class":393},[376,30745,30746],{"class":414},"\"franchise-sync.retry\"",[376,30748,3188],{"class":393},[376,30750,30293],{"class":1020},[376,30752,411],{"class":393},[376,30754,18747],{"class":560},[376,30756,3188],{"class":393},[376,30758,30324],{"class":1020},[376,30760,411],{"class":393},[376,30762,5575],{"class":560},[376,30764,3188],{"class":393},[376,30766,30333],{"class":1020},[376,30768,411],{"class":393},[376,30770,5575],{"class":560},[376,30772,3226],{"class":393},[376,30774,30775,30778,30780,30782,30785,30787,30789,30791,30793],{"class":378,"line":397},[376,30776,30777],{"class":1020},"    arguments",[376,30779,411],{"class":393},[376,30781,3197],{"class":3196},[376,30783,30784],{"class":1020}," Dictionary",[376,30786,3176],{"class":393},[376,30788,5012],{"class":3196},[376,30790,3188],{"class":393},[376,30792,19151],{"class":3196},[376,30794,18475],{"class":393},[376,30796,30797],{"class":378,"line":405},[376,30798,5704],{"class":393},[376,30800,30801,30803,30806,30808,30810,30813],{"class":378,"line":418},[376,30802,27265],{"class":393},[376,30804,30805],{"class":414},"\"x-message-ttl\"",[376,30807,22650],{"class":393},[376,30809,3240],{"class":3196},[376,30811,30812],{"class":560}," 30_000",[376,30814,3226],{"class":393},[376,30816,30817,30819,30822,30824,30826],{"class":378,"line":429},[376,30818,27265],{"class":393},[376,30820,30821],{"class":414},"\"x-dead-letter-exchange\"",[376,30823,22650],{"class":393},[376,30825,3240],{"class":3196},[376,30827,30828],{"class":414}," \"house.events\"\n",[376,30830,30831],{"class":378,"line":437},[376,30832,9554],{"class":393},[376,30834,30835],{"class":378,"line":447},[376,30836,1036],{"emptyLinePlaceholder":1035},[376,30838,30839],{"class":378,"line":531},[376,30840,30841],{"class":382},"\u002F\u002F 死信佇列：停止重試，等人處理\n",[376,30843,30844,30846,30848,30850,30853,30855,30857,30859,30861,30863,30865,30867,30869,30871,30873,30875,30877],{"class":378,"line":542},[376,30845,30173],{"class":393},[376,30847,30306],{"class":1020},[376,30849,3220],{"class":393},[376,30851,30852],{"class":414},"\"franchise-sync.dlq\"",[376,30854,3188],{"class":393},[376,30856,30293],{"class":1020},[376,30858,411],{"class":393},[376,30860,18747],{"class":560},[376,30862,3188],{"class":393},[376,30864,30324],{"class":1020},[376,30866,411],{"class":393},[376,30868,5575],{"class":560},[376,30870,3188],{"class":393},[376,30872,30333],{"class":1020},[376,30874,411],{"class":393},[376,30876,5575],{"class":560},[376,30878,3376],{"class":393},[7381,30880,30881],{},[11,30882,30883,30884,30887,30888,30891],{},"回到主 Exchange 時，訊息會以",[23,30885,30886],{},"原本的 Routing Key"," 重新路由。如果主 Exchange 上還有其他佇列綁定了相同的 Routing Key，它們也會再收到一次。比較乾淨的做法是另外建立一個只綁定主佇列的「重試回流 Exchange」，或在死信時用 ",[15,30889,30890],{},"x-dead-letter-routing-key"," 指定只有主佇列會收到的 Routing Key。",[11,30893,30894,30897],{},[23,30895,30896],{},"死信佇列不是垃圾桶","。進到這裡的訊息代表「系統自己處理不了」，要搭配監控：佇列長度大於 0 就告警，由工程師檢查原因、修正資料或程式後，再把訊息重新發回主佇列。",[28,30899,30901],{"id":30900},"使用情境加盟平台的物件同步","使用情境：加盟平台的物件同步",[11,30903,30904,30905,30908],{},"加盟平台的同步服務從 ",[15,30906,30907],{},"franchise-sync"," 佇列取出物件事件，轉換格式後寫入 Elasticsearch 與 MSSQL：",[66,30910,30911,30917,30923,30929],{},[69,30912,30913,30916],{},[23,30914,30915],{},"Elasticsearch 叢集重啟或短暫過載","：寫入逾時屬於暫時性錯誤，進入延遲重試，30 秒後通常就恢復了。重試期間主佇列的其他訊息照常處理，不會被這一筆卡住",[69,30918,30919,30922],{},[23,30920,30921],{},"主力平台送來的物件缺少必要欄位","：屬於永久性錯誤，直接送進死信佇列並告警，不會在系統裡無限重試",[69,30924,30925,30928],{},[23,30926,30927],{},"同步服務部署更新、Pod 重啟","：使用手動 Ack，處理到一半的訊息會回到佇列，由新的 Pod 接手",[69,30930,30931,30934],{},[23,30932,30933],{},"同一則事件被處理兩次","：以物件 ID 為文件 ID 做 Upsert，處理兩次結果一樣",[28,30936,1890],{"id":1890},[11,30938,30939],{},"訊息可靠性要三段一起顧：Producer 端用 Publisher Confirm 確認送達、Broker 端用 Durable 加 Persistent 撐過重啟、Consumer 端用手動 Ack 確保處理完才刪除；失敗時區分暫時性與永久性錯誤，前者用 TTL 加死信做延遲重試，後者送進死信佇列告警處理，而且因為訊息可能被處理不只一次，Consumer 一定要是冪等的。",[1132,30941,30942],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",{"title":44,"searchDepth":386,"depth":386,"links":30944},[30945,30946,30947,30948,30953,30954,30955,30956],{"id":30091,"depth":386,"text":30092},{"id":30150,"depth":386,"text":30151},{"id":30241,"depth":386,"text":30242},{"id":30377,"depth":386,"text":30378,"children":30949},[30950,30951,30952],{"id":30381,"depth":397,"text":30382},{"id":30397,"depth":397,"text":30398},{"id":30564,"depth":397,"text":30565},{"id":30639,"depth":386,"text":30640},{"id":30677,"depth":386,"text":30677},{"id":30900,"depth":386,"text":30901},{"id":1890,"depth":386,"text":1890},"2022-11-08",{},{"title":30086,"description":44},"articles\u002Frabbitmq-ack-prefetch-dead-letter",[4026,4564,1911,4565],"I6proT5pk7wM7F9MRIi7i-UVEiAibbbO2Yy9O_85LyY",{"id":30964,"title":30965,"body":30966,"date":31795,"description":44,"excerpt":1156,"extension":1157,"meta":31796,"navigation":1035,"path":9976,"seo":31797,"stem":31798,"tags":31799,"__hash__":31803},"articles\u002Farticles\u002Fpoints-ledger-idempotency.md","點數系統為什麼不能直接改餘額？用交易紀錄、冪等鍵與條件更新設計一個對得起帳的點數帳本",{"type":8,"value":30967,"toc":31786},[30968,30972,30979,31022,31025,31051,31055,31058,31064,31088,31309,31312,31316,31322,31335,31343,31349,31353,31356,31537,31539,31566,31570,31573,31697,31700,31704,31707,31775,31778,31780,31783],[28,30969,30971],{"id":30970},"直覺的設計一個餘額欄位","直覺的設計：一個餘額欄位",[11,30973,30974,30975,30978],{},"做 B 端刊登平台時，房仲經紀人要先儲值點數，再用點數購買刊登、置頂等方案。最直覺的資料模型是在會員資料表上放一個 ",[15,30976,30977],{},"Balance"," 欄位：",[36,30980,30982],{"className":9829,"code":30981,"language":9831,"meta":44,"style":44},"-- 扣點\nUPDATE Members SET Balance = Balance - 300 WHERE MemberId = @MemberId;\n",[15,30983,30984,30989],{"__ignoreMap":44},[376,30985,30986],{"class":378,"line":379},[376,30987,30988],{"class":382},"-- 扣點\n",[376,30990,30991,30993,30996,30998,31001,31003,31005,31008,31011,31014,31017,31019],{"class":378,"line":386},[376,30992,9838],{"class":3196},[376,30994,30995],{"class":393}," Members ",[376,30997,9846],{"class":3196},[376,30999,31000],{"class":393}," Balance ",[376,31002,3240],{"class":3196},[376,31004,31000],{"class":393},[376,31006,31007],{"class":3196},"-",[376,31009,31010],{"class":560}," 300",[376,31012,31013],{"class":3196}," WHERE",[376,31015,31016],{"class":393}," MemberId ",[376,31018,3240],{"class":3196},[376,31020,31021],{"class":393}," @MemberId;\n",[11,31023,31024],{},"這個設計在開發環境裡跑得很好，但只要和「錢」有關，上線後一定會遇到這三個問題：",[211,31026,31027,31033,31045],{},[69,31028,31029,31032],{},[23,31030,31031],{},"重複扣點","：使用者連點兩次「購買」、前端逾時自動重送、或是訊息佇列重複投遞，同一筆購買被扣了兩次",[69,31034,31035,31038,31039,31041,31042,31044],{},[23,31036,31037],{},"併發覆蓋","：如果程式先 ",[15,31040,26531],{}," 出餘額、在程式裡算好、再 ",[15,31043,9838],{}," 回去，兩個請求同時進來時，後寫的會蓋掉先寫的",[69,31046,31047,31050],{},[23,31048,31049],{},"對帳對不起來","：客服接到「我的點數怎麼少了」，資料庫裡只有一個「現在是 600」的數字，完全不知道它是怎麼變成 600 的",[28,31052,31054],{"id":31053},"換個角度交易紀錄才是真相","換個角度：交易紀錄才是真相",[11,31056,31057],{},"銀行的存摺不是只印「餘額」，而是一筆一筆的存入、提出，餘額只是最後一欄的加總。點數也應該這樣設計：",[11,31059,31060],{},[1952,31061],{"alt":31062,"src":31063},"點數帳本設計比較：左邊直接改餘額只留下現在的數字，會有重複扣點、併發覆蓋、無法對帳的問題；右邊以交易紀錄為主，每筆加值、扣點、退點都有唯一的 RequestId，重複請求會被唯一鍵擋下，餘額快照與交易紀錄在同一個資料庫交易內一起更新，並且永遠等於紀錄的加總","\u002Fimages\u002Farticles\u002Fpoints-ledger.svg",[66,31065,31066,31079],{},[69,31067,31068,31071,31072,31075,31076],{},[23,31069,31070],{},"交易紀錄（Ledger）","：每一次點數異動都",[23,31073,31074],{},"新增","一筆紀錄，包含異動數量、類型（加值、扣點、退點）、原因、關聯的訂單。紀錄",[23,31077,31078],{},"只新增、不修改、不刪除",[69,31080,31081,31084,31085,31087],{},[23,31082,31083],{},"餘額快照（Balance）","：為了查詢效率，另外維護一個目前餘額。但它只是",[23,31086,13671],{},"，隨時可以由交易紀錄重新加總出來",[36,31089,31091],{"className":9829,"code":31090,"language":9831,"meta":44,"style":44},"CREATE TABLE PointTransactions (\n    Id          BIGINT IDENTITY PRIMARY KEY,\n    MemberId    INT           NOT NULL,\n    RequestId   VARCHAR(64)   NOT NULL,   -- 冪等鍵\n    Type        TINYINT       NOT NULL,   -- 1 加值 \u002F 2 扣點 \u002F 3 退點\n    Amount      INT           NOT NULL,   -- 加值為正、扣點為負\n    OrderId     INT           NULL,\n    Note        NVARCHAR(200) NULL,\n    CreatedAt   DATETIME2     NOT NULL DEFAULT SYSUTCDATETIME(),\n    CONSTRAINT UQ_PointTransactions_RequestId UNIQUE (RequestId)\n);\n\nCREATE TABLE PointBalances (\n    MemberId    INT PRIMARY KEY,\n    Balance     INT NOT NULL CHECK (Balance >= 0)\n);\n",[15,31092,31093,31106,31121,31134,31159,31175,31189,31201,31220,31240,31254,31258,31262,31273,31283,31305],{"__ignoreMap":44},[376,31094,31095,31098,31100,31103],{"class":378,"line":379},[376,31096,31097],{"class":3196},"CREATE",[376,31099,26650],{"class":3196},[376,31101,31102],{"class":1020}," PointTransactions",[376,31104,31105],{"class":393}," (\n",[376,31107,31108,31111,31113,31116,31119],{"class":378,"line":386},[376,31109,31110],{"class":393},"    Id          ",[376,31112,26693],{"class":3196},[376,31114,31115],{"class":3196}," IDENTITY",[376,31117,31118],{"class":3196}," PRIMARY KEY",[376,31120,3226],{"class":393},[376,31122,31123,31126,31129,31132],{"class":378,"line":397},[376,31124,31125],{"class":393},"    MemberId    ",[376,31127,31128],{"class":3196},"INT",[376,31130,31131],{"class":3196},"           NOT NULL",[376,31133,3226],{"class":393},[376,31135,31136,31139,31142,31144,31147,31150,31153,31156],{"class":378,"line":405},[376,31137,31138],{"class":393},"    RequestId   ",[376,31140,31141],{"class":3196},"VARCHAR",[376,31143,3220],{"class":393},[376,31145,31146],{"class":560},"64",[376,31148,31149],{"class":393},")   ",[376,31151,31152],{"class":3196},"NOT NULL",[376,31154,31155],{"class":393},",   ",[376,31157,31158],{"class":382},"-- 冪等鍵\n",[376,31160,31161,31164,31167,31170,31172],{"class":378,"line":418},[376,31162,31163],{"class":3196},"    Type",[376,31165,31166],{"class":3196},"        TINYINT",[376,31168,31169],{"class":3196},"       NOT NULL",[376,31171,31155],{"class":393},[376,31173,31174],{"class":382},"-- 1 加值 \u002F 2 扣點 \u002F 3 退點\n",[376,31176,31177,31180,31182,31184,31186],{"class":378,"line":429},[376,31178,31179],{"class":393},"    Amount      ",[376,31181,31128],{"class":3196},[376,31183,31131],{"class":3196},[376,31185,31155],{"class":393},[376,31187,31188],{"class":382},"-- 加值為正、扣點為負\n",[376,31190,31191,31194,31196,31199],{"class":378,"line":437},[376,31192,31193],{"class":393},"    OrderId     ",[376,31195,31128],{"class":3196},[376,31197,31198],{"class":3196},"           NULL",[376,31200,3226],{"class":393},[376,31202,31203,31206,31209,31211,31213,31215,31218],{"class":378,"line":447},[376,31204,31205],{"class":393},"    Note        ",[376,31207,31208],{"class":3196},"NVARCHAR",[376,31210,3220],{"class":393},[376,31212,23259],{"class":560},[376,31214,5662],{"class":393},[376,31216,31217],{"class":3196},"NULL",[376,31219,3226],{"class":393},[376,31221,31222,31225,31228,31231,31234,31237],{"class":378,"line":531},[376,31223,31224],{"class":393},"    CreatedAt   ",[376,31226,31227],{"class":3196},"DATETIME2",[376,31229,31230],{"class":3196},"     NOT NULL",[376,31232,31233],{"class":3196}," DEFAULT",[376,31235,31236],{"class":560}," SYSUTCDATETIME",[376,31238,31239],{"class":393},"(),\n",[376,31241,31242,31245,31248,31251],{"class":378,"line":542},[376,31243,31244],{"class":3196},"    CONSTRAINT",[376,31246,31247],{"class":393}," UQ_PointTransactions_RequestId ",[376,31249,31250],{"class":3196},"UNIQUE",[376,31252,31253],{"class":393}," (RequestId)\n",[376,31255,31256],{"class":378,"line":552},[376,31257,3376],{"class":393},[376,31259,31260],{"class":378,"line":564},[376,31261,1036],{"emptyLinePlaceholder":1035},[376,31263,31264,31266,31268,31271],{"class":378,"line":575},[376,31265,31097],{"class":3196},[376,31267,26650],{"class":3196},[376,31269,31270],{"class":1020}," PointBalances",[376,31272,31105],{"class":393},[376,31274,31275,31277,31279,31281],{"class":378,"line":583},[376,31276,31125],{"class":393},[376,31278,31128],{"class":3196},[376,31280,31118],{"class":3196},[376,31282,3226],{"class":393},[376,31284,31285,31288,31290,31293,31296,31299,31301,31303],{"class":378,"line":597},[376,31286,31287],{"class":393},"    Balance     ",[376,31289,31128],{"class":3196},[376,31291,31292],{"class":3196}," NOT NULL",[376,31294,31295],{"class":3196}," CHECK",[376,31297,31298],{"class":393}," (Balance ",[376,31300,26606],{"class":3196},[376,31302,13153],{"class":560},[376,31304,3319],{"class":393},[376,31306,31307],{"class":378,"line":605},[376,31308,3376],{"class":393},[11,31310,31311],{},"錯了也不刪紀錄：扣錯點，就新增一筆「退點」；退錯了，再新增一筆「扣點」。每一點的來龍去脈都留在紀錄裡。",[28,31313,31315],{"id":31314},"冪等鍵同一個請求只生效一次","冪等鍵：同一個請求只生效一次",[11,31317,31318,31319,114],{},"「冪等（Idempotent）」的意思是：",[23,31320,31321],{},"同一個操作做一次和做很多次，結果都一樣",[11,31323,31324,31325,31328,31329,31332,31333,55],{},"做法是讓每一次購買都帶一個",[23,31326,31327],{},"唯一的請求識別碼（RequestId）","，例如前端在打開結帳畫面時產生一個 UUID，或直接用訂單編號組成 ",[15,31330,31331],{},"order-{OrderId}","。這個值寫進交易紀錄，並設定",[23,31334,9955],{},[66,31336,31337,31340],{},[69,31338,31339],{},"第一次請求：交易紀錄寫入成功，點數扣除",[69,31341,31342],{},"重複的請求：寫入時撞到唯一索引，整個交易失敗、點數不會再扣一次。程式捕捉到這個錯誤後，直接回傳「第一次的結果」給呼叫端",[11,31344,31345,31346,31348],{},"為什麼不在程式裡先查「這個 RequestId 有沒有處理過」再決定要不要寫？因為「先查再寫」在兩個請求",[23,31347,12957],{},"進來時，兩邊都會查到「沒有」，然後都寫進去。把判斷交給資料庫的唯一索引，才是真正可靠的保證。",[28,31350,31352],{"id":31351},"條件更新避免併發覆蓋與扣成負數","條件更新：避免併發覆蓋與扣成負數",[11,31354,31355],{},"餘額快照的更新不要「讀出來、算好、寫回去」，而是讓資料庫在一個陳述式裡完成判斷與更新：",[36,31357,31359],{"className":9829,"code":31358,"language":9831,"meta":44,"style":44},"BEGIN TRANSACTION;\n\n-- 1. 先寫交易紀錄（重複請求會在這裡被唯一索引擋下）\nINSERT INTO PointTransactions (MemberId, RequestId, Type, Amount, OrderId, Note)\nVALUES (@MemberId, @RequestId, 2, -@Cost, @OrderId, @Note);\n\n-- 2. 條件更新餘額：餘額不足就不更新\nUPDATE PointBalances\nSET    Balance = Balance - @Cost\nWHERE  MemberId = @MemberId\n  AND  Balance >= @Cost;\n\nIF @@ROWCOUNT = 0\nBEGIN\n    ROLLBACK TRANSACTION;  -- 餘額不足，連同交易紀錄一起回滾\n    THROW 50001, 'Insufficient points', 1;\nEND\n\nCOMMIT TRANSACTION;\n",[15,31360,31361,31371,31375,31380,31393,31410,31414,31419,31426,31442,31454,31466,31470,31483,31488,31500,31519,31524,31528],{"__ignoreMap":44},[376,31362,31363,31366,31369],{"class":378,"line":379},[376,31364,31365],{"class":3196},"BEGIN",[376,31367,31368],{"class":3196}," TRANSACTION",[376,31370,3428],{"class":393},[376,31372,31373],{"class":378,"line":386},[376,31374,1036],{"emptyLinePlaceholder":1035},[376,31376,31377],{"class":378,"line":397},[376,31378,31379],{"class":382},"-- 1. 先寫交易紀錄（重複請求會在這裡被唯一索引擋下）\n",[376,31381,31382,31385,31388,31390],{"class":378,"line":405},[376,31383,31384],{"class":3196},"INSERT INTO",[376,31386,31387],{"class":393}," PointTransactions (MemberId, RequestId, ",[376,31389,19400],{"class":3196},[376,31391,31392],{"class":393},", Amount, OrderId, Note)\n",[376,31394,31395,31398,31401,31403,31405,31407],{"class":378,"line":418},[376,31396,31397],{"class":3196},"VALUES",[376,31399,31400],{"class":393}," (@MemberId, @RequestId, ",[376,31402,347],{"class":560},[376,31404,3188],{"class":393},[376,31406,31007],{"class":3196},[376,31408,31409],{"class":393},"@Cost, @OrderId, @Note);\n",[376,31411,31412],{"class":378,"line":429},[376,31413,1036],{"emptyLinePlaceholder":1035},[376,31415,31416],{"class":378,"line":437},[376,31417,31418],{"class":382},"-- 2. 條件更新餘額：餘額不足就不更新\n",[376,31420,31421,31423],{"class":378,"line":447},[376,31422,9838],{"class":3196},[376,31424,31425],{"class":393}," PointBalances\n",[376,31427,31428,31430,31433,31435,31437,31439],{"class":378,"line":531},[376,31429,9846],{"class":3196},[376,31431,31432],{"class":393},"    Balance ",[376,31434,3240],{"class":3196},[376,31436,31000],{"class":393},[376,31438,31007],{"class":3196},[376,31440,31441],{"class":393}," @Cost\n",[376,31443,31444,31446,31449,31451],{"class":378,"line":542},[376,31445,9867],{"class":3196},[376,31447,31448],{"class":393},"  MemberId ",[376,31450,3240],{"class":3196},[376,31452,31453],{"class":393}," @MemberId\n",[376,31455,31456,31458,31461,31463],{"class":378,"line":552},[376,31457,26809],{"class":3196},[376,31459,31460],{"class":393},"  Balance ",[376,31462,26606],{"class":3196},[376,31464,31465],{"class":393}," @Cost;\n",[376,31467,31468],{"class":378,"line":564},[376,31469,1036],{"emptyLinePlaceholder":1035},[376,31471,31472,31475,31478,31480],{"class":378,"line":575},[376,31473,31474],{"class":3196},"IF",[376,31476,31477],{"class":393}," @@ROWCOUNT ",[376,31479,3240],{"class":3196},[376,31481,31482],{"class":560}," 0\n",[376,31484,31485],{"class":378,"line":583},[376,31486,31487],{"class":3196},"BEGIN\n",[376,31489,31490,31493,31495,31497],{"class":378,"line":597},[376,31491,31492],{"class":3196},"    ROLLBACK",[376,31494,31368],{"class":3196},[376,31496,9407],{"class":393},[376,31498,31499],{"class":382},"-- 餘額不足，連同交易紀錄一起回滾\n",[376,31501,31502,31505,31508,31510,31513,31515,31517],{"class":378,"line":605},[376,31503,31504],{"class":3196},"    THROW",[376,31506,31507],{"class":560}," 50001",[376,31509,3188],{"class":393},[376,31511,31512],{"class":414},"'Insufficient points'",[376,31514,3188],{"class":393},[376,31516,892],{"class":560},[376,31518,3428],{"class":393},[376,31520,31521],{"class":378,"line":616},[376,31522,31523],{"class":3196},"END\n",[376,31525,31526],{"class":378,"line":624},[376,31527,1036],{"emptyLinePlaceholder":1035},[376,31529,31530,31533,31535],{"class":378,"line":635},[376,31531,31532],{"class":3196},"COMMIT",[376,31534,31368],{"class":3196},[376,31536,3428],{"class":393},[11,31538,21239],{},[66,31540,31541,31547,31553,31559],{},[69,31542,31543,31546],{},[15,31544,31545],{},"Balance = Balance - @Cost"," 是在資料庫內原子地計算，兩個請求同時扣點，資料庫會依序執行，不會互相覆蓋",[69,31548,31549,31552],{},[15,31550,31551],{},"WHERE Balance >= @Cost"," 讓「檢查餘額是否足夠」和「扣除」變成同一個動作，沒有「檢查完、還沒扣之前，被別人先扣走」的空窗",[69,31554,31555,31558],{},[23,31556,31557],{},"交易紀錄和餘額在同一個資料庫交易內更新","，要嘛都成功、要嘛都失敗，兩者永遠一致",[69,31560,31561,31562,31565],{},"資料表上的 ",[15,31563,31564],{},"CHECK (Balance >= 0)"," 是最後一道防線，就算程式寫錯，資料庫也不允許出現負數餘額",[28,31567,31569],{"id":31568},"對帳餘額永遠可以重新算出來","對帳：餘額永遠可以重新算出來",[11,31571,31572],{},"因為交易紀錄是真相，對帳就是一條 SQL：",[36,31574,31576],{"className":9829,"code":31575,"language":9831,"meta":44,"style":44},"SELECT b.MemberId, b.Balance, t.Total\nFROM   PointBalances b\nJOIN  (SELECT MemberId, SUM(Amount) AS Total\n       FROM   PointTransactions\n       GROUP BY MemberId) t ON t.MemberId = b.MemberId\nWHERE  b.Balance \u003C> t.Total;\n",[15,31577,31578,31608,31615,31640,31648,31674],{"__ignoreMap":44},[376,31579,31580,31582,31585,31587,31590,31592,31595,31597,31599,31601,31603,31605],{"class":378,"line":379},[376,31581,26531],{"class":3196},[376,31583,31584],{"class":560}," b",[376,31586,4735],{"class":393},[376,31588,31589],{"class":560},"MemberId",[376,31591,3188],{"class":393},[376,31593,31594],{"class":560},"b",[376,31596,4735],{"class":393},[376,31598,30977],{"class":560},[376,31600,3188],{"class":393},[376,31602,18651],{"class":560},[376,31604,4735],{"class":393},[376,31606,31607],{"class":560},"Total\n",[376,31609,31610,31612],{"class":378,"line":386},[376,31611,26545],{"class":3196},[376,31613,31614],{"class":393},"   PointBalances b\n",[376,31616,31617,31620,31623,31625,31628,31631,31634,31637],{"class":378,"line":397},[376,31618,31619],{"class":3196},"JOIN",[376,31621,31622],{"class":393},"  (",[376,31624,26531],{"class":3196},[376,31626,31627],{"class":393}," MemberId, ",[376,31629,31630],{"class":560},"SUM",[376,31632,31633],{"class":393},"(Amount) ",[376,31635,31636],{"class":3196},"AS",[376,31638,31639],{"class":393}," Total\n",[376,31641,31642,31645],{"class":378,"line":405},[376,31643,31644],{"class":3196},"       FROM",[376,31646,31647],{"class":393},"   PointTransactions\n",[376,31649,31650,31653,31656,31659,31661,31663,31665,31667,31669,31671],{"class":378,"line":418},[376,31651,31652],{"class":3196},"       GROUP BY",[376,31654,31655],{"class":393}," MemberId) t ",[376,31657,31658],{"class":3196},"ON",[376,31660,19492],{"class":560},[376,31662,4735],{"class":393},[376,31664,31589],{"class":560},[376,31666,3313],{"class":3196},[376,31668,31584],{"class":560},[376,31670,4735],{"class":393},[376,31672,31673],{"class":560},"MemberId\n",[376,31675,31676,31678,31681,31683,31685,31688,31690,31692,31695],{"class":378,"line":429},[376,31677,9867],{"class":3196},[376,31679,31680],{"class":560},"  b",[376,31682,4735],{"class":393},[376,31684,30977],{"class":560},[376,31686,31687],{"class":3196}," \u003C>",[376,31689,19492],{"class":560},[376,31691,4735],{"class":393},[376,31693,31694],{"class":560},"Total",[376,31696,3428],{"class":393},[11,31698,31699],{},"這條查詢應該永遠回傳 0 筆。可以把它做成每天跑一次的排程，一旦有資料就發出告警。客服遇到「點數怎麼少了」的問題，也只要列出這位會員的交易紀錄，每一筆異動的時間、原因、關聯訂單都一清二楚。",[28,31701,31703],{"id":31702},"使用情境b-端刊登平台的點數","使用情境：B 端刊登平台的點數",[11,31705,31706],{},"在 B 端房仲刊登平台中，所有付費方案都以點數購買：",[132,31708,31709,31722],{},[135,31710,31711],{},[138,31712,31713,31716,31719],{},[141,31714,31715],{},"動作",[141,31717,31718],{},"交易紀錄",[141,31720,31721],{},"RequestId 範例",[152,31723,31724,31737,31750,31762],{},[138,31725,31726,31729,31732],{},[157,31727,31728],{},"經紀人信用卡儲值 1,500 點",[157,31730,31731],{},"加值 +1,500",[157,31733,31734],{},[15,31735,31736],{},"topup-{金流交易編號}",[138,31738,31739,31742,31745],{},[157,31740,31741],{},"購買「刊登 1 件 90 天」",[157,31743,31744],{},"扣點 −300",[157,31746,31747],{},[15,31748,31749],{},"order-{訂單編號}",[138,31751,31752,31755,31758],{},[157,31753,31754],{},"購買「關注焦點 7 天」",[157,31756,31757],{},"扣點 −450",[157,31759,31760],{},[15,31761,31749],{},[138,31763,31764,31767,31770],{},[157,31765,31766],{},"方案取消",[157,31768,31769],{},"退點 +450",[157,31771,31772],{},[15,31773,31774],{},"refund-{訂單編號}",[11,31776,31777],{},"儲值的 RequestId 使用金流服務回傳的交易編號：金流的付款結果通知（Callback）常常會重送好幾次，有了冪等鍵，不管通知來幾次，點數都只會加一次。",[28,31779,1890],{"id":1890},[11,31781,31782],{},"和錢有關的數字不要只存「現在是多少」，而是把每一次異動記成一筆只增不改的交易紀錄，餘額只是可以隨時重算的快照；再用唯一索引當冪等鍵擋掉重複請求、用條件更新防止併發覆蓋與負數餘額，點數帳本就能做到每一點都查得到來源、每天都對得起帳。",[1132,31784,31785],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":44,"searchDepth":386,"depth":386,"links":31787},[31788,31789,31790,31791,31792,31793,31794],{"id":30970,"depth":386,"text":30971},{"id":31053,"depth":386,"text":31054},{"id":31314,"depth":386,"text":31315},{"id":31351,"depth":386,"text":31352},{"id":31568,"depth":386,"text":31569},{"id":31702,"depth":386,"text":31703},{"id":1890,"depth":386,"text":1890},"2022-08-23",{},{"title":30965,"description":44},"articles\u002Fpoints-ledger-idempotency",[3039,31800,27013,31801,31802],"資料庫","冪等性","金流","agBkgtM-eYwGOM-0eP4Txf3qU5hNo6P7gwz9NSROvyM",{"id":31805,"title":31806,"body":31807,"date":32572,"description":44,"excerpt":1156,"extension":1157,"meta":32573,"navigation":1035,"path":3915,"seo":32574,"stem":32575,"tags":32576,"__hash__":32577},"articles\u002Farticles\u002Frabbitmq-exchange-queue-binding.md","RabbitMQ 的 Exchange、Queue、Binding：訊息到底是怎麼被送到佇列的？",{"type":8,"value":31808,"toc":32557},[31809,31813,31816,31857,31863,31874,31887,31890,31945,31948,31974,31978,31984,31988,31995,31999,32002,32006,32012,32032,32039,32144,32153,32157,32168,32172,32182,32188,32202,32208,32212,32215,32235,32242,32248,32251,32307,32310,32405,32498,32501,32521,32525,32543,32549,32551,32554],[28,31810,31812],{"id":31811},"先記住一句話producer-不直接送到-queue","先記住一句話：Producer 不直接送到 Queue",[11,31814,31815],{},"第一次接觸 RabbitMQ 時，很直覺會以為它的運作方式是「我把訊息放進某個佇列，另一邊再從佇列拿出來」。但 RabbitMQ（更精確地說，是它實作的 AMQP 0-9-1 協定）的模型多了一層：",[66,31817,31818,31830,31839,31848],{},[69,31819,31820,31822,31823,31826,31827],{},[23,31821,6028],{}," 把訊息發布（Publish）給 ",[23,31824,31825],{},"Exchange","，並附上一個 ",[23,31828,31829],{},"Routing Key",[69,31831,31832,31834,31835,31838],{},[23,31833,31825],{}," 本身",[23,31836,31837],{},"不儲存訊息","，它只負責「分派」：依照自己的類型和 Binding 規則，決定這則訊息要複製到哪些 Queue",[69,31840,31841,31844,31845,31847],{},[23,31842,31843],{},"Binding"," 是 Exchange 和 Queue 之間的一條規則，例如「Routing Key 是 ",[15,31846,119],{}," 的訊息，送到 Queue A」",[69,31849,31850,31853,31854,31856],{},[23,31851,31852],{},"Queue"," 才是真正存放訊息的地方，",[23,31855,6038],{}," 從 Queue 取出訊息處理",[11,31858,31859],{},[1952,31860],{"alt":31861,"src":31862},"RabbitMQ 核心模型示意圖：Producer 帶著 Routing Key 發布訊息到 Exchange，Exchange 依 Binding 規則把訊息分派到符合的 Queue，沒有符合 Binding 的 Queue 不會收到；Consumer 再從各自的 Queue 消費","\u002Fimages\u002Farticles\u002Frabbitmq-core-model.svg",[11,31864,31865,31866,31869,31870,31873],{},"這也解釋了最常見的「訊息不見了」：如果一則訊息送到 Exchange 時，",[23,31867,31868],{},"沒有任何一條 Binding 符合","，Exchange 就會直接把它丟掉（除非發布時設定了 ",[15,31871,31872],{},"mandatory"," 或 Exchange 設定了 Alternate Exchange）。訊息沒有進任何 Queue，自然也沒有人收到。",[7381,31875,31876],{},[11,31877,31878,31879,31882,31883,31886],{},"你可能看過「直接送到 Queue」的範例程式碼，例如 ",[15,31880,31881],{},"BasicPublish(exchange: \"\", routingKey: \"my-queue\")","。這其實是送到一個名稱為空字串的",[23,31884,31885],{},"預設 Exchange（Default Exchange）","，它是一個 Direct Exchange，而且每個 Queue 建立時都會自動以「自己的名字」綁定到它上面。所以看起來像是直接送進 Queue，本質上還是經過了 Exchange。",[28,31888,31889],{"id":31889},"三者的關係",[132,31891,31892,31906],{},[135,31893,31894],{},[138,31895,31896,31898,31900,31903],{},[141,31897,5949],{},[141,31899,13738],{},[141,31901,31902],{},"會儲存訊息嗎",[141,31904,31905],{},"誰決定它的存在",[152,31907,31908,31921,31933],{},[138,31909,31910,31912,31915,31918],{},[157,31911,31825],{},[157,31913,31914],{},"依類型與 Binding 分派訊息",[157,31916,31917],{},"不會",[157,31919,31920],{},"通常由 Producer 端宣告",[138,31922,31923,31925,31928,31930],{},[157,31924,31843],{},[157,31926,31927],{},"描述「什麼樣的訊息要進哪個 Queue」",[157,31929,31917],{},[157,31931,31932],{},"通常由 Consumer 端宣告",[138,31934,31935,31937,31940,31943],{},[157,31936,31852],{},[157,31938,31939],{},"存放訊息，等待 Consumer 取用",[157,31941,31942],{},"會",[157,31944,31932],{},[11,31946,31947],{},"幾個重要的關係：",[66,31949,31950,31961,31967],{},[69,31951,31952,31953,31956,31957,31960],{},"一個 Exchange 可以綁定",[23,31954,31955],{},"多個"," Queue，一則訊息符合幾條 Binding，就會",[23,31958,31959],{},"複製","幾份到對應的 Queue",[69,31962,31963,31964,31966],{},"一個 Queue 也可以綁定到",[23,31965,31955],{}," Exchange，或是在同一個 Exchange 上有多條不同 Routing Key 的 Binding",[69,31968,31969,31970,31973],{},"Producer 只需要知道 Exchange 的名稱，",[23,31971,31972],{},"完全不需要知道有哪些 Queue、誰在消費","——這正是用 RabbitMQ 做解耦的關鍵",[28,31975,31977],{"id":31976},"四種-exchange-類型","四種 Exchange 類型",[11,31979,31980],{},[1952,31981],{"alt":31982,"src":31983},"RabbitMQ Exchange 類型示意圖：Direct 只送到 Routing Key 完全相同的佇列；Fanout 忽略 Routing Key，送一份到每個綁定的佇列；Topic 以萬用字元比對 Routing Key，house.created.franchise 會送到 house.*.franchise、house.created.# 與 # 三個佇列，但不會送到 house.price_changed.#","\u002Fimages\u002Farticles\u002Frabbitmq-exchange-types.svg",[100,31985,31987],{"id":31986},"directrouting-key-完全相同","Direct：Routing Key 完全相同",[11,31989,31990,31991,31994],{},"Binding 時指定一個 Binding Key，訊息的 Routing Key 和它",[23,31992,31993],{},"完全相同","才會送進去。適合「這種工作交給那個佇列」的一對一分派，例如報表匯出、寄信這種背景任務。",[100,31996,31998],{"id":31997},"fanout全部送一份","Fanout：全部送一份",[11,32000,32001],{},"完全忽略 Routing Key，所有綁定在這個 Exchange 上的 Queue 都會收到一份。適合「廣播」：有一件事發生了，所有人都要知道。",[100,32003,32005],{"id":32004},"topic萬用字元比對","Topic：萬用字元比對",[11,32007,32008,32009,32011],{},"Routing Key 是以 ",[15,32010,4735],{}," 分隔的多個字，Binding Key 可以使用兩種萬用字元：",[66,32013,32014,32023],{},[69,32015,32016,32019,32020,32022],{},[15,32017,32018],{},"*","：剛好比對",[23,32021,6544],{},"字",[69,32024,32025,32028,32029,32022],{},[15,32026,32027],{},"#","：比對",[23,32030,32031],{},"零到多個",[11,32033,32034,32035,32038],{},"例如 Routing Key 為 ",[15,32036,32037],{},"house.created.franchise"," 時：",[132,32040,32041,32053],{},[135,32042,32043],{},[138,32044,32045,32048,32051],{},[141,32046,32047],{},"Binding Key",[141,32049,32050],{},"是否符合",[141,32052,11688],{},[152,32054,32055,32072,32088,32104,32115,32132],{},[138,32056,32057,32061,32064],{},[157,32058,32059],{},[15,32060,4256],{},[157,32062,32063],{},"✓",[157,32065,32066,32068,32069],{},[15,32067,32018],{}," 比對到 ",[15,32070,32071],{},"created",[138,32073,32074,32079,32081],{},[157,32075,32076],{},[15,32077,32078],{},"house.created.#",[157,32080,32063],{},[157,32082,32083,32068,32085],{},[15,32084,32027],{},[15,32086,32087],{},"franchise",[138,32089,32090,32095,32097],{},[157,32091,32092],{},[15,32093,32094],{},"house.#",[157,32096,32063],{},[157,32098,32099,32068,32101],{},[15,32100,32027],{},[15,32102,32103],{},"created.franchise",[138,32105,32106,32110,32112],{},[157,32107,32108],{},[15,32109,32027],{},[157,32111,32063],{},[157,32113,32114],{},"什麼都符合，等於 Fanout",[138,32116,32117,32122,32125],{},[157,32118,32119],{},[15,32120,32121],{},"house.*",[157,32123,32124],{},"✗",[157,32126,32127,32129,32130],{},[15,32128,32018],{}," 只能比對一個字，後面還多一個 ",[15,32131,32087],{},[138,32133,32134,32139,32141],{},[157,32135,32136],{},[15,32137,32138],{},"house.price_changed.#",[157,32140,32124],{},[157,32142,32143],{},"第二個字不同",[11,32145,32146,32147,32149,32150,114],{},"Topic 是最有彈性的類型：Binding Key 全部用完整字串時，它就是 Direct；用 ",[15,32148,32027],{}," 時，它就是 Fanout。如果一開始不確定未來的訂閱需求，",[23,32151,32152],{},"用 Topic 通常是最不會後悔的選擇",[100,32154,32156],{"id":32155},"headers依訊息標頭比對","Headers：依訊息標頭比對",[11,32158,32159,32160,32163,32164,32167],{},"不看 Routing Key，而是依訊息的 Headers 比對（可以設定全部符合 ",[15,32161,32162],{},"x-match: all"," 或任一符合 ",[15,32165,32166],{},"x-match: any","）。實務上比較少用，大部分需求 Topic 都能滿足，而且 Topic 的規則一眼就看得懂。",[28,32169,32171],{"id":32170},"同一個-queue-還是各自一個-queue","同一個 Queue 還是各自一個 Queue？",[11,32173,32174,32175,32178,32179,804],{},"這是設計時最關鍵的決定，因為它決定了訊息是「",[23,32176,32177],{},"分工","」還是「",[23,32180,32181],{},"每個人都收到",[11,32183,32184],{},[1952,32185],{"alt":32186,"src":32187},"同一個 Queue 多個 Consumer 與每個服務各自一個 Queue 的比較：左邊三個同步服務 Pod 共用 sync-queue，訊息 1、2、3 各自只被一個 Pod 拿走；右邊 house.events Exchange 綁定 sync-queue 與 notify-queue，同一則事件同步服務與通知中心各收到一份","\u002Fimages\u002Farticles\u002Frabbitmq-share-vs-copy.svg",[66,32189,32190,32196],{},[69,32191,32192,32195],{},[23,32193,32194],{},"同一個 Queue、多個 Consumer（Competing Consumers）","：一則訊息只會被其中一個 Consumer 拿走。同一個服務開了三個 Pod，三個 Pod 應該共用同一個 Queue，這樣訊息會分散到三個 Pod 處理，處理量跟著 Pod 數量水平擴展",[69,32197,32198,32201],{},[23,32199,32200],{},"每個服務各自一個 Queue","：不同的服務要對同一個事件做不同的事，就應該各自宣告自己的 Queue，綁到同一個 Exchange 上。Exchange 會複製一份到每個 Queue，每個服務各自處理、互不影響",[11,32203,32204,32205],{},"一個常見的錯誤是讓「同步服務」和「通知中心」共用同一個 Queue，結果一則事件只被其中一邊收到，另一邊莫名其妙漏資料。",[23,32206,32207],{},"判斷原則很簡單：同一個服務的多個實例共用 Queue；不同的服務各自一個 Queue。",[28,32209,32211],{"id":32210},"使用情境房仲物件事件的路由設計","使用情境：房仲物件事件的路由設計",[11,32213,32214],{},"以房仲網站為例：主力平台上的物件會新增、改價、下架，而好幾個系統都在等這些事件：",[66,32216,32217,32223,32229],{},[69,32218,32219,32222],{},[23,32220,32221],{},"加盟平台","：只關心「加盟品牌」的物件，要同步到加盟網站",[69,32224,32225,32228],{},[23,32226,32227],{},"通知中心","：有新物件刊登時，通知訂閱了這個條件的使用者",[69,32230,32231,32234],{},[23,32232,32233],{},"稽核紀錄","：所有物件異動都要留下紀錄",[11,32236,32237,32238,32241],{},"如果用 Fanout，每個系統都會收到全部的事件，再自己在程式裡過濾——加盟平台會收到大量和它無關的直營物件事件。改用 ",[23,32239,32240],{},"Topic Exchange","，把「事件類型」和「品牌類別」放進 Routing Key，讓 RabbitMQ 幫忙過濾：",[36,32243,32246],{"className":32244,"code":32245,"language":41,"meta":44},[39],"Exchange：house.events（topic）\nRouting Key 格式：house.{事件}.{品牌類別}\n\nhouse.created.franchise        加盟物件新增\nhouse.price_changed.main       直營物件改價\nhouse.removed.franchise        加盟物件下架\n",[15,32247,32245],{"__ignoreMap":44},[11,32249,32250],{},"各系統依自己的需求綁定：",[132,32252,32253,32264],{},[135,32254,32255],{},[138,32256,32257,32259,32261],{},[141,32258,31852],{},[141,32260,32047],{},[141,32262,32263],{},"收到的事件",[152,32265,32266,32279,32293],{},[138,32267,32268,32272,32276],{},[157,32269,32270],{},[15,32271,30907],{},[157,32273,32274],{},[15,32275,4256],{},[157,32277,32278],{},"所有加盟物件的異動",[138,32280,32281,32286,32290],{},[157,32282,32283],{},[15,32284,32285],{},"notify-new-listing",[157,32287,32288],{},[15,32289,32078],{},[157,32291,32292],{},"所有新刊登的物件",[138,32294,32295,32300,32304],{},[157,32296,32297],{},[15,32298,32299],{},"audit-log",[157,32301,32302],{},[15,32303,32027],{},[157,32305,32306],{},"全部",[11,32308,32309],{},"宣告的程式碼（以 RabbitMQ.Client 為例）：",[36,32311,32313],{"className":3145,"code":32312,"language":3147,"meta":44,"style":44},"\u002F\u002F Producer 端：只需要宣告 Exchange\nchannel.ExchangeDeclare(\"house.events\", ExchangeType.Topic, durable: true);\n\nvar body = JsonSerializer.SerializeToUtf8Bytes(houseCreatedEvent);\nvar props = channel.CreateBasicProperties();\nprops.Persistent = true;\n\nchannel.BasicPublish(\"house.events\", \"house.created.franchise\", props, body);\n",[15,32314,32315,32320,32340,32344,32361,32375,32385,32389],{"__ignoreMap":44},[376,32316,32317],{"class":378,"line":379},[376,32318,32319],{"class":382},"\u002F\u002F Producer 端：只需要宣告 Exchange\n",[376,32321,32322,32324,32326,32328,32330,32332,32334,32336,32338],{"class":378,"line":386},[376,32323,30173],{"class":393},[376,32325,30283],{"class":1020},[376,32327,3220],{"class":393},[376,32329,30193],{"class":414},[376,32331,30290],{"class":393},[376,32333,30293],{"class":1020},[376,32335,411],{"class":393},[376,32337,18747],{"class":560},[376,32339,3376],{"class":393},[376,32341,32342],{"class":378,"line":397},[376,32343,1036],{"emptyLinePlaceholder":1035},[376,32345,32346,32348,32351,32353,32355,32358],{"class":378,"line":405},[376,32347,4684],{"class":3196},[376,32349,32350],{"class":1020}," body",[376,32352,3313],{"class":3196},[376,32354,12563],{"class":393},[376,32356,32357],{"class":1020},"SerializeToUtf8Bytes",[376,32359,32360],{"class":393},"(houseCreatedEvent);\n",[376,32362,32363,32365,32367,32369,32371,32373],{"class":378,"line":418},[376,32364,4684],{"class":3196},[376,32366,30350],{"class":1020},[376,32368,3313],{"class":3196},[376,32370,30355],{"class":393},[376,32372,30358],{"class":1020},[376,32374,3532],{"class":393},[376,32376,32377,32379,32381,32383],{"class":378,"line":429},[376,32378,30365],{"class":393},[376,32380,3240],{"class":3196},[376,32382,3802],{"class":560},[376,32384,3428],{"class":393},[376,32386,32387],{"class":378,"line":437},[376,32388,1036],{"emptyLinePlaceholder":1035},[376,32390,32391,32393,32395,32397,32399,32401,32403],{"class":378,"line":447},[376,32392,30173],{"class":393},[376,32394,30156],{"class":1020},[376,32396,3220],{"class":393},[376,32398,30193],{"class":414},[376,32400,3188],{"class":393},[376,32402,30198],{"class":414},[376,32404,30201],{"class":393},[36,32406,32408],{"className":3145,"code":32407,"language":3147,"meta":44,"style":44},"\u002F\u002F 加盟平台（Consumer 端）：宣告自己的 Queue 與 Binding\nchannel.ExchangeDeclare(\"house.events\", ExchangeType.Topic, durable: true);\nchannel.QueueDeclare(\"franchise-sync\", durable: true, exclusive: false, autoDelete: false);\nchannel.QueueBind(\"franchise-sync\", \"house.events\", routingKey: \"house.*.franchise\");\n",[15,32409,32410,32415,32435,32471],{"__ignoreMap":44},[376,32411,32412],{"class":378,"line":379},[376,32413,32414],{"class":382},"\u002F\u002F 加盟平台（Consumer 端）：宣告自己的 Queue 與 Binding\n",[376,32416,32417,32419,32421,32423,32425,32427,32429,32431,32433],{"class":378,"line":386},[376,32418,30173],{"class":393},[376,32420,30283],{"class":1020},[376,32422,3220],{"class":393},[376,32424,30193],{"class":414},[376,32426,30290],{"class":393},[376,32428,30293],{"class":1020},[376,32430,411],{"class":393},[376,32432,18747],{"class":560},[376,32434,3376],{"class":393},[376,32436,32437,32439,32441,32443,32445,32447,32449,32451,32453,32455,32457,32459,32461,32463,32465,32467,32469],{"class":378,"line":397},[376,32438,30173],{"class":393},[376,32440,30306],{"class":1020},[376,32442,3220],{"class":393},[376,32444,30311],{"class":414},[376,32446,3188],{"class":393},[376,32448,30293],{"class":1020},[376,32450,411],{"class":393},[376,32452,18747],{"class":560},[376,32454,3188],{"class":393},[376,32456,30324],{"class":1020},[376,32458,411],{"class":393},[376,32460,5575],{"class":560},[376,32462,3188],{"class":393},[376,32464,30333],{"class":1020},[376,32466,411],{"class":393},[376,32468,5575],{"class":560},[376,32470,3376],{"class":393},[376,32472,32473,32475,32478,32480,32482,32484,32486,32488,32491,32493,32496],{"class":378,"line":405},[376,32474,30173],{"class":393},[376,32476,32477],{"class":1020},"QueueBind",[376,32479,3220],{"class":393},[376,32481,30311],{"class":414},[376,32483,3188],{"class":393},[376,32485,30193],{"class":414},[376,32487,3188],{"class":393},[376,32489,32490],{"class":1020},"routingKey",[376,32492,411],{"class":393},[376,32494,32495],{"class":414},"\"house.*.franchise\"",[376,32497,3376],{"class":393},[11,32499,32500],{},"這樣設計的好處：",[66,32502,32503,32512,32518],{},[69,32504,32505,32508,32509],{},[23,32506,32507],{},"主力平台完全不知道加盟平台的存在","，它只負責把事件發到 ",[15,32510,32511],{},"house.events",[69,32513,32514,32515],{},"之後多一個系統要訂閱物件事件，只要新增一個 Queue 和 Binding，",[23,32516,32517],{},"主力平台一行程式都不用改",[69,32519,32520],{},"過濾在 Broker 端完成，加盟平台不會收到、也不用處理直營物件的事件",[100,32522,32524],{"id":32523},"fanout-的情境通知所有實例清快取","Fanout 的情境：通知所有實例清快取",[11,32526,32527,32528,32531,32532,2276,32535,32538,32539,32542],{},"Fanout 最適合「每個實例都要做同一件事」的場景。例如物件資料更新後，所有 Web 實例的本機快取都要清掉：每個實例啟動時宣告一個",[23,32529,32530],{},"自己專屬、斷線就自動刪除","的 Queue（",[15,32533,32534],{},"exclusive: true",[15,32536,32537],{},"autoDelete: true","），綁到 ",[15,32540,32541],{},"cache.invalidate"," 這個 Fanout Exchange 上。發一則訊息，所有實例都會收到。",[11,32544,32545,32546,32548],{},"注意這和前面「同一個服務共用 Queue」剛好相反：清快取是",[23,32547,4652],{},"，所以每個實例要有自己的 Queue。",[28,32550,1890],{"id":1890},[11,32552,32553],{},"Producer 把訊息交給 Exchange，Exchange 依 Binding 決定複製到哪些 Queue，Consumer 從 Queue 拿訊息；想讓同一個服務分工就共用 Queue，想讓不同服務各自收到就各自建 Queue，而 Topic Exchange 搭配設計良好的 Routing Key，可以讓 Broker 幫你完成大部分的過濾工作。",[1132,32555,32556],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":44,"searchDepth":386,"depth":386,"links":32558},[32559,32560,32561,32567,32568,32571],{"id":31811,"depth":386,"text":31812},{"id":31889,"depth":386,"text":31889},{"id":31976,"depth":386,"text":31977,"children":32562},[32563,32564,32565,32566],{"id":31986,"depth":397,"text":31987},{"id":31997,"depth":397,"text":31998},{"id":32004,"depth":397,"text":32005},{"id":32155,"depth":397,"text":32156},{"id":32170,"depth":386,"text":32171},{"id":32210,"depth":386,"text":32211,"children":32569},[32570],{"id":32523,"depth":397,"text":32524},{"id":1890,"depth":386,"text":1890},"2022-06-14",{},{"title":31806,"description":44},"articles\u002Frabbitmq-exchange-queue-binding",[4026,4564,4565,3039],"is8HIfimNVmoPE03Xu0wnF_KkToge8X8CLtuEzy2me4",{"id":32579,"title":32580,"body":32581,"date":33997,"description":33998,"excerpt":1156,"extension":1157,"meta":33999,"navigation":1035,"path":27825,"seo":34000,"stem":34001,"tags":34002,"__hash__":34005},"articles\u002Farticles\u002Felasticsearch-getting-started.md","Elasticsearch 入門：從 RDBMS 概念對照到 Query DSL 與 Aggregation 實戰",{"type":8,"value":32582,"toc":33975},[32583,32604,32608,32661,32665,32668,32672,32675,32701,32705,32708,32793,32796,32800,32803,32850,32854,32857,32879,32897,32901,32904,33052,33055,33120,33123,33199,33203,33223,33305,33313,33328,33332,33346,33354,33417,33423,33427,33438,33521,33525,33537,33541,33544,33617,33639,33643,33664,33783,33800,33804,33809,33903,33914,33917,33929,33932,33936,33939,33941,33972],[11,32584,32585,32586,11886,32590,32594,32595,32598,32599,114],{},"這篇是從實際專案（",[119,32587,32589],{"href":32588},"\u002Fportfolio\u002Frealty-platform-rebuild","房仲平台核心系統重構",[119,32591,32593],{"href":32592},"\u002Fportfolio\u002Ffranchise-realty-platform","加盟房仲平台"," 等）用 Elasticsearch 支\n撐物件搜尋的經驗整理出來的入門教學，不求涵蓋所有官方文件的細節，而是把剛接觸\nElasticsearch 時最容易卡住的幾個概念，用關聯式資料庫（RDBMS）的角度對照講清\n楚。完整範例查詢與 ",[15,32596,32597],{},"docker-compose"," 環境放在\n",[119,32600,32603],{"href":32601,"rel":32602},"https:\u002F\u002Fgithub.com\u002Ftom3019\u002FHappyElasticSearch",[3057],"HappyElasticSearch",[28,32605,32607],{"id":32606},"先建立心智模型elasticsearch-對應-rdbms-的什麼","先建立心智模型：Elasticsearch 對應 RDBMS 的什麼",[132,32609,32610,32619],{},[135,32611,32612],{},[138,32613,32614,32617],{},[141,32615,32616],{},"RDBMS",[141,32618,25673],{},[152,32620,32621,32629,32637,32645,32653],{},[138,32622,32623,32626],{},[157,32624,32625],{},"Table",[157,32627,32628],{},"Index",[138,32630,32631,32634],{},[157,32632,32633],{},"Row",[157,32635,32636],{},"Document",[138,32638,32639,32642],{},[157,32640,32641],{},"Column",[157,32643,32644],{},"Field",[138,32646,32647,32650],{},[157,32648,32649],{},"Schema",[157,32651,32652],{},"Mapping",[138,32654,32655,32658],{},[157,32656,32657],{},"SQL",[157,32659,32660],{},"DSL（Query DSL，用 JSON 表示查詢）",[28,32662,32664],{"id":32663},"mapping定義欄位型別","Mapping：定義欄位型別",[11,32666,32667],{},"Mapping 定義 Document 裡每個欄位的型別，等同於在 RDBMS 定義 Table 的欄位型\n別。",[100,32669,32671],{"id":32670},"dynamic-mapping動態映射","Dynamic Mapping（動態映射）",[11,32673,32674],{},"Elasticsearch 預設是動態映射：只要新增 Document 進 Index，就會依資料值自動\n推斷欄位型別，不需要事先定義。",[11,32676,32677,32680,32681,3565,32683,32685,32686,32688,32689,32691,32692,32695,32696,32698,32699,114],{},[23,32678,32679],{},"要注意的地方","：字串欄位經過 Dynamic Mapping 後，預設會同時產生 ",[15,32682,41],{},[15,32684,28806],{}," 兩個子欄位——",[15,32687,41],{}," 用於全文檢索（會被斷詞），",[15,32690,28806],{}," 用於精確比\n對與排序、聚合（不斷詞）。這代表",[23,32693,32694],{},"同一個字串值會被存兩份","。如果某個欄位你確\n定只會拿來做精確比對（例如關鍵字標籤），讓它預設同時保留 ",[15,32697,41],{}," 反而浪費儲存\n空間，應該用 Dynamic Mapping Template 或 Explicit Mapping 把它限定成只有\n",[15,32700,28806],{},[100,32702,32704],{"id":32703},"explicit-mapping明確映射","Explicit Mapping（明確映射）",[11,32706,32707],{},"想完全自己控制型別時，可以在建立 Index 時就宣告 Mapping：",[36,32709,32711],{"className":25359,"code":32710,"language":25361,"meta":44,"style":44},"PUT \u002FindexName\n{\n  \"mappings\": {\n    \"properties\": {\n      \"name\":     { \"type\": \"text\" },\n      \"account\":  { \"type\": \"text\" },\n      \"password\": { \"type\": \"keyword\" }\n    }\n  }\n}\n",[15,32712,32713,32718,32722,32728,32734,32749,32765,32781,32785,32789],{"__ignoreMap":44},[376,32714,32715],{"class":378,"line":379},[376,32716,32717],{"class":393},"PUT \u002FindexName\n",[376,32719,32720],{"class":378,"line":386},[376,32721,3324],{"class":393},[376,32723,32724,32726],{"class":378,"line":397},[376,32725,27855],{"class":560},[376,32727,27858],{"class":393},[376,32729,32730,32732],{"class":378,"line":405},[376,32731,27863],{"class":560},[376,32733,27858],{"class":393},[376,32735,32736,32739,32741,32743,32745,32747],{"class":378,"line":418},[376,32737,32738],{"class":560},"      \"name\"",[376,32740,28168],{"class":393},[376,32742,27876],{"class":560},[376,32744,411],{"class":393},[376,32746,27881],{"class":414},[376,32748,25454],{"class":393},[376,32750,32751,32754,32757,32759,32761,32763],{"class":378,"line":429},[376,32752,32753],{"class":560},"      \"account\"",[376,32755,32756],{"class":393},":  { ",[376,32758,27876],{"class":560},[376,32760,411],{"class":393},[376,32762,27881],{"class":414},[376,32764,25454],{"class":393},[376,32766,32767,32770,32772,32774,32776,32779],{"class":378,"line":437},[376,32768,32769],{"class":560},"      \"password\"",[376,32771,3589],{"class":393},[376,32773,27876],{"class":560},[376,32775,411],{"class":393},[376,32777,32778],{"class":414},"\"keyword\"",[376,32780,3610],{"class":393},[376,32782,32783],{"class":378,"line":447},[376,32784,5766],{"class":393},[376,32786,32787],{"class":378,"line":531},[376,32788,15700],{"class":393},[376,32790,32791],{"class":378,"line":542},[376,32792,3381],{"class":393},[11,32794,32795],{},"也可以先建 Index 再補上映射；但既有欄位的型別一旦寫入資料就無法直接改，只能\n重建 Index 或用 Reindex API 遷移。",[28,32797,32799],{"id":32798},"查詢結果的欄位怎麼看response-結構","查詢結果的欄位怎麼看（Response 結構）",[11,32801,32802],{},"一次查詢的回應大致長這樣，理解這幾個欄位對讀查詢結果很關鍵：",[66,32804,32805,32811,32820,32826,32832,32838,32844],{},[69,32806,32807,32810],{},[15,32808,32809],{},"hits.total.value","：符合查詢條件的資料總數",[69,32812,32813,55,32816,32819],{},[15,32814,32815],{},"hits.total.relation",[15,32817,32818],{},"eq"," 表示 total 是精確值（沒有被截斷估算）",[69,32821,32822,32825],{},[15,32823,32824],{},"hits.max_score","：整次查詢中最高的匹配分數",[69,32827,32828,32831],{},[15,32829,32830],{},"hits.hits[]._index","：資料所在的 Index",[69,32833,32834,32837],{},[15,32835,32836],{},"hits.hits[]._id","：Document 的 ID",[69,32839,32840,32843],{},[15,32841,32842],{},"hits.hits[]._score","：這筆資料的匹配分數，分數越高代表跟查詢條件越相關",[69,32845,32846,32849],{},[15,32847,32848],{},"hits.hits[]._source","：實際的資料內容",[28,32851,32853],{"id":32852},"query-dsl兩種查詢情境","Query DSL：兩種查詢情境",[11,32855,32856],{},"Elasticsearch 的查詢分成兩種 Context，效能特性完全不同：",[66,32858,32859,32869],{},[69,32860,32861,32864,32865,32868],{},[23,32862,32863],{},"Query Context","：會計算匹配分數（",[15,32866,32867],{},"_score","），分數越高排序越前面；子查詢\n結果與分數會被快取",[69,32870,32871,32874,32875,32878],{},[23,32872,32873],{},"Filter Context","：只回答「符合或不符合」，",[23,32876,32877],{},"不計算分數","，處理速度更\n快，也不快取有相關性算分的查詢",[7381,32880,32881],{},[11,32882,32883,32884,20073,32886,32889,32890,11886,32893,32896],{},"⚠️ 效能建議：不需要排序相關性的條件（例如「狀態是否為上架中」「日期是否在\n某個範圍」）優先用 ",[15,32885,15207],{},[15,32887,32888],{},"must","；也盡量避免用 ",[15,32891,32892],{},"Script",[15,32894,32895],{},"Wildcard","，這兩種查詢方式很容易吃滿效能。",[100,32898,32900],{"id":32899},"boolean-query複合查詢","Boolean Query（複合查詢）",[11,32902,32903],{},"如同 RDBMS 的多條件組合查詢：",[36,32905,32907],{"className":25359,"code":32906,"language":25361,"meta":44,"style":44},"GET \u002Fkibana_sample_data_ecommerce\u002F_search\n{\n  \"query\": {\n    \"bool\": {\n      \"must\": [\n        { \"term\": { \"order_date\": { \"value\": \"2022-02-03\" } } },\n        { \"term\": { \"day_of_week\": { \"value\": \"Thursday\" } } }\n      ],\n      \"should\": [\n        { \"term\": { \"customer_id\": { \"value\": 26 } } },\n        { \"term\": { \"customer_id\": { \"value\": 27 } } }\n      ]\n    }\n  }\n}\n",[15,32908,32909,32914,32918,32924,32930,32937,32960,32982,32986,32993,33015,33036,33040,33044,33048],{"__ignoreMap":44},[376,32910,32911],{"class":378,"line":379},[376,32912,32913],{"class":393},"GET \u002Fkibana_sample_data_ecommerce\u002F_search\n",[376,32915,32916],{"class":378,"line":386},[376,32917,3324],{"class":393},[376,32919,32920,32922],{"class":378,"line":397},[376,32921,28125],{"class":560},[376,32923,27858],{"class":393},[376,32925,32926,32928],{"class":378,"line":405},[376,32927,28132],{"class":560},[376,32929,27858],{"class":393},[376,32931,32932,32935],{"class":378,"line":418},[376,32933,32934],{"class":560},"      \"must\"",[376,32936,28142],{"class":393},[376,32938,32939,32941,32943,32945,32948,32950,32953,32955,32958],{"class":378,"line":429},[376,32940,28230],{"class":393},[376,32942,28444],{"class":560},[376,32944,3589],{"class":393},[376,32946,32947],{"class":560},"\"order_date\"",[376,32949,3589],{"class":393},[376,32951,32952],{"class":560},"\"value\"",[376,32954,411],{"class":393},[376,32956,32957],{"class":414},"\"2022-02-03\"",[376,32959,28250],{"class":393},[376,32961,32962,32964,32966,32968,32971,32973,32975,32977,32980],{"class":378,"line":437},[376,32963,28230],{"class":393},[376,32965,28444],{"class":560},[376,32967,3589],{"class":393},[376,32969,32970],{"class":560},"\"day_of_week\"",[376,32972,3589],{"class":393},[376,32974,32952],{"class":560},[376,32976,411],{"class":393},[376,32978,32979],{"class":414},"\"Thursday\"",[376,32981,28273],{"class":393},[376,32983,32984],{"class":378,"line":447},[376,32985,28430],{"class":393},[376,32987,32988,32991],{"class":378,"line":531},[376,32989,32990],{"class":560},"      \"should\"",[376,32992,28142],{"class":393},[376,32994,32995,32997,32999,33001,33004,33006,33008,33010,33013],{"class":378,"line":542},[376,32996,28230],{"class":393},[376,32998,28444],{"class":560},[376,33000,3589],{"class":393},[376,33002,33003],{"class":560},"\"customer_id\"",[376,33005,3589],{"class":393},[376,33007,32952],{"class":560},[376,33009,411],{"class":393},[376,33011,33012],{"class":560},"26",[376,33014,28250],{"class":393},[376,33016,33017,33019,33021,33023,33025,33027,33029,33031,33034],{"class":378,"line":552},[376,33018,28230],{"class":393},[376,33020,28444],{"class":560},[376,33022,3589],{"class":393},[376,33024,33003],{"class":560},[376,33026,3589],{"class":393},[376,33028,32952],{"class":560},[376,33030,411],{"class":393},[376,33032,33033],{"class":560},"27",[376,33035,28273],{"class":393},[376,33037,33038],{"class":378,"line":564},[376,33039,28278],{"class":393},[376,33041,33042],{"class":378,"line":575},[376,33043,5766],{"class":393},[376,33045,33046],{"class":378,"line":583},[376,33047,15700],{"class":393},[376,33049,33050],{"class":378,"line":597},[376,33051,3381],{"class":393},[11,33053,33054],{},"對應的 SQL 概念大致是：",[36,33056,33058],{"className":9829,"code":33057,"language":9831,"meta":44,"style":44},"SELECT * FROM TABLE\nWHERE order_date = '2022-02-03'\n  AND day_of_week = 'Thursday'\n  AND (customer_id = 26 OR customer_id = 27)\n",[15,33059,33060,33071,33083,33095],{"__ignoreMap":44},[376,33061,33062,33064,33066,33068],{"class":378,"line":379},[376,33063,26531],{"class":3196},[376,33065,24854],{"class":3196},[376,33067,27728],{"class":3196},[376,33069,33070],{"class":3196}," TABLE\n",[376,33072,33073,33075,33078,33080],{"class":378,"line":386},[376,33074,9867],{"class":3196},[376,33076,33077],{"class":393}," order_date ",[376,33079,3240],{"class":3196},[376,33081,33082],{"class":414}," '2022-02-03'\n",[376,33084,33085,33087,33090,33092],{"class":378,"line":397},[376,33086,26809],{"class":3196},[376,33088,33089],{"class":393}," day_of_week ",[376,33091,3240],{"class":3196},[376,33093,33094],{"class":414}," 'Thursday'\n",[376,33096,33097,33099,33102,33104,33107,33110,33113,33115,33118],{"class":378,"line":405},[376,33098,26809],{"class":3196},[376,33100,33101],{"class":393}," (customer_id ",[376,33103,3240],{"class":3196},[376,33105,33106],{"class":560}," 26",[376,33108,33109],{"class":3196}," OR",[376,33111,33112],{"class":393}," customer_id ",[376,33114,3240],{"class":3196},[376,33116,33117],{"class":560}," 27",[376,33119,3319],{"class":393},[11,33121,33122],{},"四種子句的角色：",[132,33124,33125,33137],{},[135,33126,33127],{},[138,33128,33129,33132,33134],{},[141,33130,33131],{},"子句",[141,33133,7310],{},[141,33135,33136],{},"對應邏輯",[152,33138,33139,33153,33170,33185],{},[138,33140,33141,33145,33148],{},[157,33142,33143],{},[15,33144,32888],{},[157,33146,33147],{},"必須符合，會計算分數",[157,33149,33150,33151],{},"相當於 ",[15,33152,9878],{},[138,33154,33155,33159,33165],{},[157,33156,33157],{},[15,33158,15207],{},[157,33160,33161,33162,33164],{},"必須符合，",[23,33163,32877],{},"，速度較快",[157,33166,33150,33167,33169],{},[15,33168,9878],{},"，建議優先使用",[138,33171,33172,33177,33180],{},[157,33173,33174],{},[15,33175,33176],{},"should",[157,33178,33179],{},"符合會加分，非必要條件",[157,33181,33150,33182],{},[15,33183,33184],{},"OR",[138,33186,33187,33191,33194],{},[157,33188,33189],{},[15,33190,28556],{},[157,33192,33193],{},"必須不符合",[157,33195,33150,33196],{},[15,33197,33198],{},"NOT",[100,33200,33202],{"id":33201},"全文檢索full-text-queries","全文檢索（Full Text Queries）",[11,33204,33205,33210,33211,6342,33214,29010,33216,33218,33219,33222],{},[23,33206,33207],{},[15,33208,33209],{},"match","：標準的全文查詢，支援 ",[15,33212,33213],{},"operator",[15,33215,9878],{},[15,33217,33184],{},"）與 ",[15,33220,33221],{},"fuzziness","\n（模糊比對，容忍拼字誤差）：",[36,33224,33226],{"className":25359,"code":33225,"language":25361,"meta":44,"style":44},"GET \u002Fkibana_sample_data_ecommerce\u002F_search\n{\n  \"query\": {\n    \"match\": {\n      \"customer_id\": {\n        \"query\": 27,\n        \"operator\": \"and\",\n        \"fuzziness\": \"AUTO\"\n      }\n    }\n  }\n}\n",[15,33227,33228,33232,33236,33242,33249,33256,33267,33279,33289,33293,33297,33301],{"__ignoreMap":44},[376,33229,33230],{"class":378,"line":379},[376,33231,32913],{"class":393},[376,33233,33234],{"class":378,"line":386},[376,33235,3324],{"class":393},[376,33237,33238,33240],{"class":378,"line":397},[376,33239,28125],{"class":560},[376,33241,27858],{"class":393},[376,33243,33244,33247],{"class":378,"line":405},[376,33245,33246],{"class":560},"    \"match\"",[376,33248,27858],{"class":393},[376,33250,33251,33254],{"class":378,"line":418},[376,33252,33253],{"class":560},"      \"customer_id\"",[376,33255,27858],{"class":393},[376,33257,33258,33261,33263,33265],{"class":378,"line":429},[376,33259,33260],{"class":560},"        \"query\"",[376,33262,411],{"class":393},[376,33264,33033],{"class":560},[376,33266,3226],{"class":393},[376,33268,33269,33272,33274,33277],{"class":378,"line":437},[376,33270,33271],{"class":560},"        \"operator\"",[376,33273,411],{"class":393},[376,33275,33276],{"class":414},"\"and\"",[376,33278,3226],{"class":393},[376,33280,33281,33284,33286],{"class":378,"line":447},[376,33282,33283],{"class":560},"        \"fuzziness\"",[376,33285,411],{"class":393},[376,33287,33288],{"class":414},"\"AUTO\"\n",[376,33290,33291],{"class":378,"line":531},[376,33292,28536],{"class":393},[376,33294,33295],{"class":378,"line":542},[376,33296,5766],{"class":393},[376,33298,33299],{"class":378,"line":552},[376,33300,15700],{"class":393},[376,33302,33303],{"class":378,"line":564},[376,33304,3381],{"class":393},[11,33306,33307,33312],{},[23,33308,33309],{},[15,33310,33311],{},"multi_match","：同一個查詢值同時比對多個欄位，很適合「搜尋框」這種不知道\n使用者要查哪個欄位的場景。",[11,33314,33315,33320,33321,29010,33324,33327],{},[23,33316,33317],{},[15,33318,33319],{},"match_all","：查詢全部資料，務必搭配 ",[15,33322,33323],{},"size",[15,33325,33326],{},"from"," 分頁使用，避免一次把\n整個 Index 撈出來。",[100,33329,33331],{"id":33330},"欄位等級查詢term-level-queries","欄位等級查詢（Term-level Queries）",[11,33333,33334,33342,33343,33345],{},[23,33335,33336,33339,33340],{},[15,33337,33338],{},"term"," \u002F ",[15,33341,28810],{},"：精確比對單一或多個值（不做全文分詞），適合查\n",[15,33344,28806],{},"、數字、日期這類欄位。",[11,33347,33348,33353],{},[23,33349,33350],{},[15,33351,33352],{},"range","：範圍查詢：",[36,33355,33357],{"className":25359,"code":33356,"language":25361,"meta":44,"style":44},"GET \u002Fkibana_sample_data_ecommerce\u002F_search\n{\n  \"query\": {\n    \"range\": {\n      \"order_date\": { \"gte\": \"2022-01-01\", \"lte\": \"2022-01-31\" }\n    }\n  }\n}\n",[15,33358,33359,33363,33367,33373,33380,33405,33409,33413],{"__ignoreMap":44},[376,33360,33361],{"class":378,"line":379},[376,33362,32913],{"class":393},[376,33364,33365],{"class":378,"line":386},[376,33366,3324],{"class":393},[376,33368,33369,33371],{"class":378,"line":397},[376,33370,28125],{"class":560},[376,33372,27858],{"class":393},[376,33374,33375,33378],{"class":378,"line":405},[376,33376,33377],{"class":560},"    \"range\"",[376,33379,27858],{"class":393},[376,33381,33382,33385,33387,33389,33391,33394,33396,33398,33400,33403],{"class":378,"line":418},[376,33383,33384],{"class":560},"      \"order_date\"",[376,33386,3589],{"class":393},[376,33388,28266],{"class":560},[376,33390,411],{"class":393},[376,33392,33393],{"class":414},"\"2022-01-01\"",[376,33395,3188],{"class":393},[376,33397,28242],{"class":560},[376,33399,411],{"class":393},[376,33401,33402],{"class":414},"\"2022-01-31\"",[376,33404,3610],{"class":393},[376,33406,33407],{"class":378,"line":429},[376,33408,5766],{"class":393},[376,33410,33411],{"class":378,"line":437},[376,33412,15700],{"class":393},[376,33414,33415],{"class":378,"line":447},[376,33416,3381],{"class":393},[11,33418,33419,33420,114],{},"對應 SQL：",[15,33421,33422],{},"WHERE order_date BETWEEN '2022-01-01' AND '2022-01-31'",[100,33424,33426],{"id":33425},"只要欄位不要全部資料","只要欄位，不要全部資料",[11,33428,9999,33429,17363,33431,29010,33434,33437],{},[15,33430,28314],{},[15,33432,33433],{},"includes",[15,33435,33436],{},"excludes"," 控制回傳欄位，減少不必要的網路傳輸：",[36,33439,33441],{"className":25359,"code":33440,"language":25361,"meta":44,"style":44},"GET \u002Fkibana_sample_data_ecommerce\u002F_search\n{\n  \"size\": 20,\n  \"from\": 0,\n  \"query\": {},\n  \"_source\": {\n    \"excludes\": [\"order_id\", \"order_date\"],\n    \"includes\": [\"*\"]\n  }\n}\n",[15,33442,33443,33447,33451,33461,33472,33479,33485,33501,33513,33517],{"__ignoreMap":44},[376,33444,33445],{"class":378,"line":379},[376,33446,32913],{"class":393},[376,33448,33449],{"class":378,"line":386},[376,33450,3324],{"class":393},[376,33452,33453,33455,33457,33459],{"class":378,"line":397},[376,33454,28093],{"class":560},[376,33456,411],{"class":393},[376,33458,23581],{"class":560},[376,33460,3226],{"class":393},[376,33462,33463,33466,33468,33470],{"class":378,"line":405},[376,33464,33465],{"class":560},"  \"from\"",[376,33467,411],{"class":393},[376,33469,4975],{"class":560},[376,33471,3226],{"class":393},[376,33473,33474,33476],{"class":378,"line":418},[376,33475,28125],{"class":560},[376,33477,33478],{"class":393},": {},\n",[376,33480,33481,33483],{"class":378,"line":429},[376,33482,28104],{"class":560},[376,33484,27858],{"class":393},[376,33486,33487,33490,33492,33495,33497,33499],{"class":378,"line":437},[376,33488,33489],{"class":560},"    \"excludes\"",[376,33491,7712],{"class":393},[376,33493,33494],{"class":414},"\"order_id\"",[376,33496,3188],{"class":393},[376,33498,32947],{"class":414},[376,33500,28120],{"class":393},[376,33502,33503,33506,33508,33511],{"class":378,"line":447},[376,33504,33505],{"class":560},"    \"includes\"",[376,33507,7712],{"class":393},[376,33509,33510],{"class":414},"\"*\"",[376,33512,7718],{"class":393},[376,33514,33515],{"class":378,"line":531},[376,33516,15700],{"class":393},[376,33518,33519],{"class":378,"line":542},[376,33520,3381],{"class":393},[28,33522,33524],{"id":33523},"aggregationes-版的-group-by-與統計函式","Aggregation：ES 版的 GROUP BY 與統計函式",[11,33526,33527,33528,2276,33530,2276,33533,33536],{},"Aggregation 提供類似 RDBMS ",[15,33529,29437],{},[15,33531,33532],{},"COUNT",[15,33534,33535],{},"AVG"," 的能力，分成三種：",[100,33538,33540],{"id":33539},"bucket-aggregation分桶相當於-group-by","Bucket Aggregation：分桶（相當於 GROUP BY）",[11,33542,33543],{},"依欄位值把 Document 分到不同的桶：",[36,33545,33547],{"className":25359,"code":33546,"language":25361,"meta":44,"style":44},"GET \u002FIndexName\u002F_search\n{\n  \"size\": 0,\n  \"aggs\": {\n    \"day\": {\n      \"terms\": { \"field\": \"day_of_week\", \"size\": 10 }\n    }\n  }\n}\n",[15,33548,33549,33554,33558,33568,33574,33581,33605,33609,33613],{"__ignoreMap":44},[376,33550,33551],{"class":378,"line":379},[376,33552,33553],{"class":393},"GET \u002FIndexName\u002F_search\n",[376,33555,33556],{"class":378,"line":386},[376,33557,3324],{"class":393},[376,33559,33560,33562,33564,33566],{"class":378,"line":397},[376,33561,28093],{"class":560},[376,33563,411],{"class":393},[376,33565,4975],{"class":560},[376,33567,3226],{"class":393},[376,33569,33570,33572],{"class":378,"line":405},[376,33571,28719],{"class":560},[376,33573,27858],{"class":393},[376,33575,33576,33579],{"class":378,"line":418},[376,33577,33578],{"class":560},"    \"day\"",[376,33580,27858],{"class":393},[376,33582,33583,33586,33588,33590,33592,33594,33596,33599,33601,33603],{"class":378,"line":429},[376,33584,33585],{"class":560},"      \"terms\"",[376,33587,3589],{"class":393},[376,33589,28738],{"class":560},[376,33591,411],{"class":393},[376,33593,32970],{"class":414},[376,33595,3188],{"class":393},[376,33597,33598],{"class":560},"\"size\"",[376,33600,411],{"class":393},[376,33602,361],{"class":560},[376,33604,3610],{"class":393},[376,33606,33607],{"class":378,"line":437},[376,33608,5766],{"class":393},[376,33610,33611],{"class":378,"line":447},[376,33612,15700],{"class":393},[376,33614,33615],{"class":378,"line":531},[376,33616,3381],{"class":393},[11,33618,33619,33620,33623,33624,33627,33628,33631,33632,17065,33635,33638],{},"回傳結果會依 ",[15,33621,33622],{},"day_of_week"," 分組，每組附上該值出現的次數（",[15,33625,33626],{},"doc_count","），例如\n",[15,33629,33630],{},"\"key\": \"Thursday\", \"doc_count\": 775","。這裡的 ",[15,33633,33634],{},"\"size\": 0",[23,33636,33637],{},"不要回傳原\n始查詢命中的資料","，只要聚合結果——查詢結果本身用不到時，這樣可以省掉大量不\n必要的資料傳輸。",[100,33640,33642],{"id":33641},"metric-aggregation數學運算","Metric Aggregation：數學運算",[11,33644,33645,29010,33648,29010,33651,29010,33654,29010,33657,33660,33661,55],{},[15,33646,33647],{},"min",[15,33649,33650],{},"max",[15,33652,33653],{},"avg",[15,33655,33656],{},"sum",[15,33658,33659],{},"cardinality","，或是一次拿到全部統計值的\n",[15,33662,33663],{},"stats",[36,33665,33667],{"className":25359,"code":33666,"language":25361,"meta":44,"style":44},"GET \u002FIndexName\u002F_search\n{\n  \"size\": 0,\n  \"aggs\": {\n    \"productsBaseAvg\": { \"avg\": { \"field\": \"products.base_price\" } },\n    \"productsBaseMax\": { \"max\": { \"field\": \"products.base_price\" } },\n    \"productsBaseMin\": { \"min\": { \"field\": \"products.base_price\" } },\n    \"productsBaseStats\": { \"stats\": { \"field\": \"products.base_price\" } }\n  }\n}\n",[15,33668,33669,33673,33677,33687,33693,33715,33735,33755,33775,33779],{"__ignoreMap":44},[376,33670,33671],{"class":378,"line":379},[376,33672,33553],{"class":393},[376,33674,33675],{"class":378,"line":386},[376,33676,3324],{"class":393},[376,33678,33679,33681,33683,33685],{"class":378,"line":397},[376,33680,28093],{"class":560},[376,33682,411],{"class":393},[376,33684,4975],{"class":560},[376,33686,3226],{"class":393},[376,33688,33689,33691],{"class":378,"line":405},[376,33690,28719],{"class":560},[376,33692,27858],{"class":393},[376,33694,33695,33698,33700,33703,33705,33707,33709,33712],{"class":378,"line":418},[376,33696,33697],{"class":560},"    \"productsBaseAvg\"",[376,33699,3589],{"class":393},[376,33701,33702],{"class":560},"\"avg\"",[376,33704,3589],{"class":393},[376,33706,28738],{"class":560},[376,33708,411],{"class":393},[376,33710,33711],{"class":414},"\"products.base_price\"",[376,33713,33714],{"class":393}," } },\n",[376,33716,33717,33720,33722,33725,33727,33729,33731,33733],{"class":378,"line":429},[376,33718,33719],{"class":560},"    \"productsBaseMax\"",[376,33721,3589],{"class":393},[376,33723,33724],{"class":560},"\"max\"",[376,33726,3589],{"class":393},[376,33728,28738],{"class":560},[376,33730,411],{"class":393},[376,33732,33711],{"class":414},[376,33734,33714],{"class":393},[376,33736,33737,33740,33742,33745,33747,33749,33751,33753],{"class":378,"line":437},[376,33738,33739],{"class":560},"    \"productsBaseMin\"",[376,33741,3589],{"class":393},[376,33743,33744],{"class":560},"\"min\"",[376,33746,3589],{"class":393},[376,33748,28738],{"class":560},[376,33750,411],{"class":393},[376,33752,33711],{"class":414},[376,33754,33714],{"class":393},[376,33756,33757,33760,33762,33765,33767,33769,33771,33773],{"class":378,"line":447},[376,33758,33759],{"class":560},"    \"productsBaseStats\"",[376,33761,3589],{"class":393},[376,33763,33764],{"class":560},"\"stats\"",[376,33766,3589],{"class":393},[376,33768,28738],{"class":560},[376,33770,411],{"class":393},[376,33772,33711],{"class":414},[376,33774,28455],{"class":393},[376,33776,33777],{"class":378,"line":531},[376,33778,15700],{"class":393},[376,33780,33781],{"class":378,"line":542},[376,33782,3381],{"class":393},[11,33784,33785,33787,33788,29010,33791,29010,33793,29010,33795,29010,33797,33799],{},[15,33786,33663],{}," 一次回傳 ",[15,33789,33790],{},"count",[15,33792,33647],{},[15,33794,33650],{},[15,33796,33653],{},[15,33798,33656],{},"，不用分開寫四個聚合。",[100,33801,33803],{"id":33802},"pipeline-aggregation對聚合結果再聚合巢狀分組","Pipeline Aggregation：對聚合結果再聚合（巢狀分組）",[11,33805,33806,33807,55],{},"在一個 Bucket Aggregation 裡面再放一個 Bucket Aggregation，達到多層分組，\n相當於 RDBMS 多欄位的 ",[15,33808,29437],{},[36,33810,33812],{"className":25359,"code":33811,"language":25361,"meta":44,"style":44},"GET \u002FIndexName\u002F_search\n{\n  \"size\": 0,\n  \"aggs\": {\n    \"county\": {\n      \"terms\": { \"field\": \"county.keyword\" },\n      \"aggs\": {\n        \"city\": { \"terms\": { \"field\": \"district.keyword\" } }\n      }\n    }\n  }\n}\n",[15,33813,33814,33818,33822,33832,33838,33845,33860,33866,33887,33891,33895,33899],{"__ignoreMap":44},[376,33815,33816],{"class":378,"line":379},[376,33817,33553],{"class":393},[376,33819,33820],{"class":378,"line":386},[376,33821,3324],{"class":393},[376,33823,33824,33826,33828,33830],{"class":378,"line":397},[376,33825,28093],{"class":560},[376,33827,411],{"class":393},[376,33829,4975],{"class":560},[376,33831,3226],{"class":393},[376,33833,33834,33836],{"class":378,"line":405},[376,33835,28719],{"class":560},[376,33837,27858],{"class":393},[376,33839,33840,33843],{"class":378,"line":418},[376,33841,33842],{"class":560},"    \"county\"",[376,33844,27858],{"class":393},[376,33846,33847,33849,33851,33853,33855,33858],{"class":378,"line":429},[376,33848,33585],{"class":560},[376,33850,3589],{"class":393},[376,33852,28738],{"class":560},[376,33854,411],{"class":393},[376,33856,33857],{"class":414},"\"county.keyword\"",[376,33859,25454],{"class":393},[376,33861,33862,33864],{"class":378,"line":437},[376,33863,28759],{"class":560},[376,33865,27858],{"class":393},[376,33867,33868,33871,33873,33876,33878,33880,33882,33885],{"class":378,"line":447},[376,33869,33870],{"class":560},"        \"city\"",[376,33872,3589],{"class":393},[376,33874,33875],{"class":560},"\"terms\"",[376,33877,3589],{"class":393},[376,33879,28738],{"class":560},[376,33881,411],{"class":393},[376,33883,33884],{"class":414},"\"district.keyword\"",[376,33886,28455],{"class":393},[376,33888,33889],{"class":378,"line":531},[376,33890,28536],{"class":393},[376,33892,33893],{"class":378,"line":542},[376,33894,5766],{"class":393},[376,33896,33897],{"class":378,"line":552},[376,33898,15700],{"class":393},[376,33900,33901],{"class":378,"line":564},[376,33902,3381],{"class":393},[11,33904,33905,33906,33909,33910,33913],{},"結果會先依 ",[15,33907,33908],{},"county","（縣市）分組，每個縣市底下再依 ",[15,33911,33912],{},"district","（行政區）分\n組——像是「台北市 → 大安區、內湖區」「新北市 → 板橋區、新莊區」這樣的階層統\n計。",[28,33915,33916],{"id":33916},"本機環境",[36,33918,33920],{"className":11852,"code":33919,"language":11854,"meta":44,"style":44},"docker-compose up\n",[15,33921,33922],{"__ignoreMap":44},[376,33923,33924,33926],{"class":378,"line":379},[376,33925,32597],{"class":1020},[376,33927,33928],{"class":414}," up\n",[11,33930,33931],{},"會啟動對應版本的 Elasticsearch 與 Kibana，直接在 Kibana 的 Dev Tools 貼上面\n的查詢範例就能跑。",[28,33933,33935],{"id":33934},"延伸在-aspnet-core-中使用","延伸：在 ASP.NET Core 中使用",[11,33937,33938],{},"倉庫裡也附了一個分層（Application／Service／Repository）的 ASP.NET Core 範\n例專案，示範怎麼把上面的查詢邏輯包裝進一般後端服務的架構裡，而不是散落在各處\n直接寫死 DSL 字串。",[28,33940,1095],{"id":1095},[66,33942,33943,33946,33957,33966],{},[69,33944,33945],{},"把 Mapping／Document／Index 對應回 Schema／Row／Table，能少走很多彎路",[69,33947,33948,33949,33951,33952,33954,33955],{},"字串欄位預設會存兩份（",[15,33950,41],{}," + ",[15,33953,28806],{},"），確定只需要精確比對時就該用\nExplicit Mapping 限定成 ",[15,33956,28806],{},[69,33958,33959,33960,33962,33963,33965],{},"不需要算分數的條件優先用 ",[15,33961,15207],{},"，效能比 ",[15,33964,32888],{}," 好",[69,33967,33968,33969,33971],{},"只要聚合結果、不需要原始資料時，",[15,33970,33634],{}," 能省下大量不必要的傳輸",[1132,33973,33974],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}",{"title":44,"searchDepth":386,"depth":386,"links":33976},[33977,33978,33982,33983,33989,33994,33995,33996],{"id":32606,"depth":386,"text":32607},{"id":32663,"depth":386,"text":32664,"children":33979},[33980,33981],{"id":32670,"depth":397,"text":32671},{"id":32703,"depth":397,"text":32704},{"id":32798,"depth":386,"text":32799},{"id":32852,"depth":386,"text":32853,"children":33984},[33985,33986,33987,33988],{"id":32899,"depth":397,"text":32900},{"id":33201,"depth":397,"text":33202},{"id":33330,"depth":397,"text":33331},{"id":33425,"depth":397,"text":33426},{"id":33523,"depth":386,"text":33524,"children":33990},[33991,33992,33993],{"id":33539,"depth":397,"text":33540},{"id":33641,"depth":397,"text":33642},{"id":33802,"depth":397,"text":33803},{"id":33916,"depth":386,"text":33916},{"id":33934,"depth":386,"text":33935},{"id":1095,"depth":386,"text":1095},"2022-03-18","這篇是從實際專案（房仲平台核心系統重構、\n加盟房仲平台 等）用 Elasticsearch 支\n撐物件搜尋的經驗整理出來的入門教學，不求涵蓋所有官方文件的細節，而是把剛接觸\nElasticsearch 時最容易卡住的幾個概念，用關聯式資料庫（RDBMS）的角度對照講清\n楚。完整範例查詢與 docker-compose 環境放在\nHappyElasticSearch。",{},{"title":32580,"description":33998},"articles\u002Felasticsearch-getting-started",[25673,34003,34004,31800],"Kibana","NoSQL","hf86G5jsCEg7KwDnt-tOEtYK_BtP1Bkt0udh5O34MPE",{"id":34007,"title":34008,"body":34009,"date":35256,"description":44,"excerpt":1156,"extension":1157,"meta":35257,"navigation":1035,"path":16904,"seo":35258,"stem":35259,"tags":35260,"__hash__":35261},"articles\u002Farticles\u002Fk8s-core-objects.md","Kubernetes 入門：Pod、Deployment、Service、Ingress 是怎麼把一個請求送到容器的",{"type":8,"value":34010,"toc":35242},[34011,34015,34018,34032,34042,34045,34051,34054,34081,34084,34088,34096,34102,34120,34130,34133,34137,34142,34171,34174,34178,34189,34212,34219,34223,34232,34258,34261,34315,34324,34328,34333,34339,34346,34349,34353,34356,34370,34389,34393,34409,34423,34427,34430,35037,35040,35149,35159,35162,35234,35236,35239],[28,34012,34014],{"id":34013},"kubernetes-在解決什麼問題","Kubernetes 在解決什麼問題",[11,34016,34017],{},"用 Docker 把服務包成容器之後，接下來的問題是：",[66,34019,34020,34023,34026,34029],{},[69,34021,34022],{},"要跑 3 份，其中一份掛了，誰負責把它重新開起來？",[69,34024,34025],{},"3 份容器的 IP 都不一樣、重開還會變，前面的人要連哪一個？",[69,34027,34028],{},"要更新版本時，怎麼一份一份換，而不是全部停掉再開？",[69,34030,34031],{},"設定檔、密碼要怎麼給容器，又不要寫死在映像檔裡？",[11,34033,34034,34035,34038,34039,114],{},"Kubernetes（K8s）做的事情，就是讓你",[23,34036,34037],{},"用 YAML 描述「期望的狀態」","——「我要 3 份這個版本的 API，對外開 80 Port」——然後由它持續比對實際狀態，不一樣就自動修正。你不再是一步步下指令，而是",[23,34040,34041],{},"宣告結果",[28,34043,34044],{"id":34044},"一個請求的路徑",[11,34046,34047],{},[1952,34048],{"alt":34049,"src":34050},"Kubernetes 請求路徑示意圖：使用者的請求先到 Ingress Controller，依 Ingress 規則中的網域 api.example.com 與路徑 \u002Fhouses 轉給 house-api 這個 Service；Service 有固定的 ClusterIP 與 DNS 名稱，以 Label Selector app=house-api 找到三個 Pod 並做負載平衡；三個 Pod 由 Deployment 透過 ReplicaSet 維持數量，Deployment 指定映像檔版本與副本數；Pod 的設定來自 ConfigMap，密碼來自 Secret；全部位於 realty 這個 Namespace 中","\u002Fimages\u002Farticles\u002Fk8s-request-path.svg",[11,34052,34053],{},"從外到內，一個請求會經過：",[211,34055,34056,34062,34068,34074],{},[69,34057,34058,34061],{},[23,34059,34060],{},"Ingress","：依網域和路徑，決定要轉給哪個 Service",[69,34063,34064,34067],{},[23,34065,34066],{},"Service","：一個固定的入口，把請求平均分給後面的 Pod",[69,34069,34070,34073],{},[23,34071,34072],{},"Pod","：真正在跑容器的地方",[69,34075,34076,34077,34080],{},"而 Pod 的數量和版本，由 ",[23,34078,34079],{},"Deployment"," 管理",[11,34082,34083],{},"下面由內到外，一個一個看。",[28,34085,34087],{"id":34086},"pod最小的部署單位","Pod：最小的部署單位",[11,34089,34090,34092,34093,34095],{},[23,34091,34072],{}," 是 K8s 裡最小的單位，裡面放一個（偶爾多個）容器。同一個 Pod 裡的容器共用網路，可以用 ",[15,34094,14979],{}," 互相連線。",[11,34097,34098,34099,114],{},"Pod 最重要的特性是：",[23,34100,34101],{},"它是用完即丟的",[66,34103,34104,34111,34117],{},[69,34105,34106,34107,34110],{},"Pod 掛了，K8s 不會「修好」它，而是",[23,34108,34109],{},"建立一個新的 Pod"," 取代",[69,34112,34113,34114],{},"新的 Pod 會有",[23,34115,34116],{},"新的名稱和新的 IP",[69,34118,34119],{},"Pod 裡寫入的檔案，Pod 被刪掉就沒了",[11,34121,34122,34123,1540,34126,34129],{},"所以兩件事要記住：",[23,34124,34125],{},"不要依賴 Pod 的 IP",[23,34127,34128],{},"不要把資料存在 Pod 裡","。需要保存的資料，要放到資料庫、物件儲存，或是掛載 PersistentVolume。",[11,34131,34132],{},"實務上幾乎不會直接建立 Pod，而是透過 Deployment。",[28,34134,34136],{"id":34135},"deployment-與-replicaset維持數量管理版本","Deployment 與 ReplicaSet：維持數量、管理版本",[11,34138,34139,34141],{},[23,34140,34079],{}," 描述「我要哪個映像檔、跑幾份」：",[66,34143,34144,34155,34165],{},[69,34145,34146,34147,34150,34151,34154],{},"它會建立一個 ",[23,34148,34149],{},"ReplicaSet","，由 ReplicaSet 負責「",[23,34152,34153],{},"隨時維持 3 個 Pod","」。一個 Pod 被刪掉，ReplicaSet 馬上補一個",[69,34156,34157,34158,34161,34162],{},"更新映像檔版本時，Deployment 會建立一個",[23,34159,34160],{},"新的 ReplicaSet","，逐步把新版本的 Pod 加上去、舊版本的減下來，這就是",[23,34163,34164],{},"滾動更新（Rolling Update）",[69,34166,34167,34168,34170],{},"新版本有問題，",[15,34169,17778],{}," 就能退回上一個 ReplicaSet",[11,34172,34173],{},"一般只需要操作 Deployment，ReplicaSet 由它自動管理。",[28,34175,34177],{"id":34176},"label-與-selector物件之間怎麼找到彼此","Label 與 Selector：物件之間怎麼找到彼此",[11,34179,34180,34181,34184,34185,34188],{},"K8s 的物件之間",[23,34182,34183],{},"不是用名稱互相指定","，而是用 ",[23,34186,34187],{},"Label","（標籤）：",[66,34190,34191,34198,34205],{},[69,34192,34193,34194,34197],{},"Pod 身上貼著 ",[15,34195,34196],{},"app: house-api"," 這個 Label",[69,34199,34200,34201,34204],{},"Deployment 用 ",[15,34202,34203],{},"selector.matchLabels: app: house-api"," 來認領「哪些 Pod 歸我管」",[69,34206,34207,34208,34211],{},"Service 也用 ",[15,34209,34210],{},"selector: app: house-api"," 決定「要把流量送給哪些 Pod」",[11,34213,34214,34215,34218],{},"這個設計很有彈性，但也是新手最常出錯的地方：",[23,34216,34217],{},"Service 的 Selector 和 Pod 的 Label 只要有一個字不一樣，Service 就找不到任何 Pod","，請求一律失敗，而且 YAML 套用時不會有任何錯誤。",[28,34220,34222],{"id":34221},"service固定的入口","Service：固定的入口",[11,34224,34225,34226,34229,34230,55],{},"Pod 的 IP 會變、數量會變，所以前面需要一個",[23,34227,34228],{},"固定的入口","，這就是 ",[23,34231,34066],{},[66,34233,34234,34249,34255],{},[69,34235,34236,34237,34240,34241,34244,34245,34248],{},"有一個",[23,34238,34239],{},"固定的虛擬 IP（ClusterIP）","，和一個 ",[23,34242,34243],{},"DNS 名稱","，例如同一個 Namespace 裡直接用 ",[15,34246,34247],{},"http:\u002F\u002Fhouse-api"," 就能連到",[69,34250,34251,34252],{},"依 Selector 找到所有符合的 Pod，",[23,34253,34254],{},"只把請求送給已經就緒（Ready）的 Pod",[69,34256,34257],{},"Pod 增加、減少、重建時，Service 自動更新後面的清單",[11,34259,34260],{},"Service 有幾種類型：",[132,34262,34263,34274],{},[135,34264,34265],{},[138,34266,34267,34269,34272],{},[141,34268,16504],{},[141,34270,34271],{},"誰能連",[141,34273,18185],{},[152,34275,34276,34289,34302],{},[138,34277,34278,34283,34286],{},[157,34279,34280,6448],{},[15,34281,34282],{},"ClusterIP",[157,34284,34285],{},"只有叢集內部",[157,34287,34288],{},"服務之間互相呼叫，最常用",[138,34290,34291,34296,34299],{},[157,34292,34293],{},[15,34294,34295],{},"NodePort",[157,34297,34298],{},"叢集外，透過任一 Node 的 IP 加固定 Port（30000～32767）",[157,34300,34301],{},"測試、沒有負載平衡器的環境",[138,34303,34304,34309,34312],{},[157,34305,34306],{},[15,34307,34308],{},"LoadBalancer",[157,34310,34311],{},"叢集外，透過雲端建立的負載平衡器",[157,34313,34314],{},"在雲端直接對外開放單一服務",[11,34316,34317,34318,34320,34321,34323],{},"如果每個要對外的服務都用 ",[15,34319,34308],{},"，雲端上就會建立一堆負載平衡器，成本很高。所以通常內部都用 ",[15,34322,34282],{},"，對外統一經過 Ingress。",[28,34325,34327],{"id":34326},"ingress依網域與路徑分流","Ingress：依網域與路徑分流",[11,34329,34330,34332],{},[23,34331,34060],{}," 是 HTTP 層的路由規則：",[36,34334,34337],{"className":34335,"code":34336,"language":41,"meta":44},[39],"api.example.com\u002Fhouses   → house-api Service\napi.example.com\u002Fmembers  → member-api Service\nadmin.example.com        → admin-web Service\n",[15,34338,34336],{"__ignoreMap":44},[11,34340,34341,34342,34345],{},"要注意，Ingress 只是「規則」，",[23,34343,34344],{},"真正處理流量的是 Ingress Controller","（例如 NGINX Ingress Controller），它要另外安裝在叢集裡。只寫了 Ingress、卻沒有安裝 Controller，規則不會有任何作用。",[11,34347,34348],{},"TLS 憑證也通常在 Ingress 這一層處理，後面的服務只需要處理 HTTP。",[28,34350,34352],{"id":34351},"configmap-與-secret設定與密碼","ConfigMap 與 Secret：設定與密碼",[11,34354,34355],{},"設定不應該寫死在映像檔裡，否則每個環境都要建一個不同的映像檔：",[66,34357,34358,34364],{},[69,34359,34360,34363],{},[23,34361,34362],{},"ConfigMap","：一般設定，例如外部 API 的網址、功能開關",[69,34365,34366,34369],{},[23,34367,34368],{},"Secret","：敏感資料，例如資料庫連線字串、API Key",[11,34371,34372,34373,34376,34377,34380,34381,34384,34385,34388],{},"兩者都可以用",[23,34374,34375],{},"環境變數","或",[23,34378,34379],{},"掛載成檔案","的方式給 Pod。ASP.NET Core 會自動讀取環境變數，而且 ",[15,34382,34383],{},"ConnectionStrings__Default"," 這種用兩個底線的名稱，會對應到設定裡的 ",[15,34386,34387],{},"ConnectionStrings:Default","，不用改程式。",[28,34390,34392],{"id":34391},"namespace分隔環境與團隊","Namespace：分隔環境與團隊",[11,34394,34395,34398,34399,2276,34402,2276,34405,34408],{},[23,34396,34397],{},"Namespace"," 把叢集切成多個邏輯區塊，例如 ",[15,34400,34401],{},"realty",[15,34403,34404],{},"member",[15,34406,34407],{},"monitoring","。不同 Namespace 可以有同名的 Service，也可以設定各自的資源配額與權限。",[11,34410,34411,34412,34415,34416,34419,34420,5226],{},"同一個 Namespace 裡用 ",[15,34413,34414],{},"house-api"," 就能連到 Service；跨 Namespace 要寫完整名稱 ",[15,34417,34418],{},"house-api.realty.svc.cluster.local","（或簡寫 ",[15,34421,34422],{},"house-api.realty",[28,34424,34426],{"id":34425},"使用情境部署一個-net-api","使用情境：部署一個 .NET API",[11,34428,34429],{},"把物件查詢 API 部署上去，需要的完整 YAML：",[36,34431,34433],{"className":370,"code":34432,"language":372,"meta":44,"style":44},"apiVersion: apps\u002Fv1\nkind: Deployment\nmetadata:\n  name: house-api\n  namespace: realty\nspec:\n  replicas: 3\n  selector:\n    matchLabels:\n      app: house-api            # 認領帶有這個 Label 的 Pod\n  template:\n    metadata:\n      labels:\n        app: house-api          # Pod 的 Label，要和上面、和 Service 一致\n    spec:\n      containers:\n        - name: house-api\n          image: registry.example.com\u002Fhouse-api:1.3.0   # 不要用 latest\n          ports:\n            - containerPort: 8080\n          env:\n            - name: ASPNETCORE_URLS\n              value: http:\u002F\u002F+:8080\n            - name: ConnectionStrings__Default\n              valueFrom:\n                secretKeyRef:\n                  name: house-api-secret\n                  key: db-connection\n          envFrom:\n            - configMapRef:\n                name: house-api-config\n          resources:\n            requests:\n              cpu: 250m\n              memory: 256Mi\n            limits:\n              memory: 512Mi\n          readinessProbe:\n            httpGet:\n              path: \u002Fhealth\u002Fready\n              port: 8080\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: house-api\n  namespace: realty\nspec:\n  selector:\n    app: house-api\n  ports:\n    - port: 80                  # Service 對內的 Port\n      targetPort: 8080          # 轉到容器的 Port\n---\napiVersion: networking.k8s.io\u002Fv1\nkind: Ingress\nmetadata:\n  name: house-api\n  namespace: realty\nspec:\n  ingressClassName: nginx\n  rules:\n    - host: api.example.com\n      http:\n        paths:\n          - path: \u002Fhouses\n            pathType: Prefix\n            backend:\n              service:\n                name: house-api\n                port:\n                  number: 80\n",[15,34434,34435,34443,34451,34457,34465,34474,34480,34488,34494,34500,34511,34517,34523,34530,34542,34548,34555,34565,34578,34585,34597,34604,34615,34625,34636,34643,34650,34660,34670,34677,34686,34696,34703,34710,34720,34730,34737,34745,34752,34759,34768,34777,34781,34790,34799,34805,34813,34821,34827,34833,34842,34849,34863,34875,34879,34888,34897,34903,34911,34919,34925,34935,34942,34954,34962,34970,34982,34993,35001,35009,35018,35026],{"__ignoreMap":44},[376,34436,34437,34439,34441],{"class":378,"line":379},[376,34438,472],{"class":389},[376,34440,411],{"class":393},[376,34442,528],{"class":414},[376,34444,34445,34447,34449],{"class":378,"line":386},[376,34446,482],{"class":389},[376,34448,411],{"class":393},[376,34450,539],{"class":414},[376,34452,34453,34455],{"class":378,"line":397},[376,34454,492],{"class":389},[376,34456,394],{"class":393},[376,34458,34459,34461,34463],{"class":378,"line":405},[376,34460,499],{"class":389},[376,34462,411],{"class":393},[376,34464,17273],{"class":414},[376,34466,34467,34469,34471],{"class":378,"line":418},[376,34468,7100],{"class":389},[376,34470,411],{"class":393},[376,34472,34473],{"class":414},"realty\n",[376,34475,34476,34478],{"class":378,"line":429},[376,34477,509],{"class":389},[376,34479,394],{"class":393},[376,34481,34482,34484,34486],{"class":378,"line":437},[376,34483,16995],{"class":389},[376,34485,411],{"class":393},[376,34487,3638],{"class":560},[376,34489,34490,34492],{"class":378,"line":447},[376,34491,17630],{"class":389},[376,34493,394],{"class":393},[376,34495,34496,34498],{"class":378,"line":531},[376,34497,17637],{"class":389},[376,34499,394],{"class":393},[376,34501,34502,34504,34506,34508],{"class":378,"line":542},[376,34503,17644],{"class":389},[376,34505,411],{"class":393},[376,34507,34414],{"class":414},[376,34509,34510],{"class":382},"            # 認領帶有這個 Label 的 Pod\n",[376,34512,34513,34515],{"class":378,"line":552},[376,34514,7628],{"class":389},[376,34516,394],{"class":393},[376,34518,34519,34521],{"class":378,"line":564},[376,34520,7635],{"class":389},[376,34522,394],{"class":393},[376,34524,34525,34528],{"class":378,"line":575},[376,34526,34527],{"class":389},"      labels",[376,34529,394],{"class":393},[376,34531,34532,34535,34537,34539],{"class":378,"line":583},[376,34533,34534],{"class":389},"        app",[376,34536,411],{"class":393},[376,34538,34414],{"class":414},[376,34540,34541],{"class":382},"          # Pod 的 Label，要和上面、和 Service 一致\n",[376,34543,34544,34546],{"class":378,"line":597},[376,34545,7652],{"class":389},[376,34547,394],{"class":393},[376,34549,34550,34553],{"class":378,"line":605},[376,34551,34552],{"class":389},"      containers",[376,34554,394],{"class":393},[376,34556,34557,34559,34561,34563],{"class":378,"line":616},[376,34558,875],{"class":393},[376,34560,17268],{"class":389},[376,34562,411],{"class":393},[376,34564,17273],{"class":414},[376,34566,34567,34570,34572,34575],{"class":378,"line":624},[376,34568,34569],{"class":389},"          image",[376,34571,411],{"class":393},[376,34573,34574],{"class":414},"registry.example.com\u002Fhouse-api:1.3.0",[376,34576,34577],{"class":382},"   # 不要用 latest\n",[376,34579,34580,34583],{"class":378,"line":635},[376,34581,34582],{"class":389},"          ports",[376,34584,394],{"class":393},[376,34586,34587,34590,34593,34595],{"class":378,"line":5815},[376,34588,34589],{"class":393},"            - ",[376,34591,34592],{"class":389},"containerPort",[376,34594,411],{"class":393},[376,34596,17144],{"class":560},[376,34598,34599,34602],{"class":378,"line":7233},[376,34600,34601],{"class":389},"          env",[376,34603,394],{"class":393},[376,34605,34606,34608,34610,34612],{"class":378,"line":7243},[376,34607,34589],{"class":393},[376,34609,17268],{"class":389},[376,34611,411],{"class":393},[376,34613,34614],{"class":414},"ASPNETCORE_URLS\n",[376,34616,34617,34620,34622],{"class":378,"line":7251},[376,34618,34619],{"class":389},"              value",[376,34621,411],{"class":393},[376,34623,34624],{"class":414},"http:\u002F\u002F+:8080\n",[376,34626,34627,34629,34631,34633],{"class":378,"line":7721},[376,34628,34589],{"class":393},[376,34630,17268],{"class":389},[376,34632,411],{"class":393},[376,34634,34635],{"class":414},"ConnectionStrings__Default\n",[376,34637,34638,34641],{"class":378,"line":7729},[376,34639,34640],{"class":389},"              valueFrom",[376,34642,394],{"class":393},[376,34644,34645,34648],{"class":378,"line":7740},[376,34646,34647],{"class":389},"                secretKeyRef",[376,34649,394],{"class":393},[376,34651,34652,34655,34657],{"class":378,"line":9569},[376,34653,34654],{"class":389},"                  name",[376,34656,411],{"class":393},[376,34658,34659],{"class":414},"house-api-secret\n",[376,34661,34662,34665,34667],{"class":378,"line":9581},[376,34663,34664],{"class":389},"                  key",[376,34666,411],{"class":393},[376,34668,34669],{"class":414},"db-connection\n",[376,34671,34672,34675],{"class":378,"line":9590},[376,34673,34674],{"class":389},"          envFrom",[376,34676,394],{"class":393},[376,34678,34679,34681,34684],{"class":378,"line":9595},[376,34680,34589],{"class":393},[376,34682,34683],{"class":389},"configMapRef",[376,34685,394],{"class":393},[376,34687,34688,34691,34693],{"class":378,"line":9600},[376,34689,34690],{"class":389},"                name",[376,34692,411],{"class":393},[376,34694,34695],{"class":414},"house-api-config\n",[376,34697,34698,34701],{"class":378,"line":9605},[376,34699,34700],{"class":389},"          resources",[376,34702,394],{"class":393},[376,34704,34705,34708],{"class":378,"line":9615},[376,34706,34707],{"class":389},"            requests",[376,34709,394],{"class":393},[376,34711,34712,34715,34717],{"class":378,"line":9639},[376,34713,34714],{"class":389},"              cpu",[376,34716,411],{"class":393},[376,34718,34719],{"class":414},"250m\n",[376,34721,34722,34725,34727],{"class":378,"line":9651},[376,34723,34724],{"class":389},"              memory",[376,34726,411],{"class":393},[376,34728,34729],{"class":414},"256Mi\n",[376,34731,34732,34735],{"class":378,"line":9656},[376,34733,34734],{"class":389},"            limits",[376,34736,394],{"class":393},[376,34738,34739,34741,34743],{"class":378,"line":13527},[376,34740,34724],{"class":389},[376,34742,411],{"class":393},[376,34744,426],{"class":414},[376,34746,34747,34750],{"class":378,"line":13532},[376,34748,34749],{"class":389},"          readinessProbe",[376,34751,394],{"class":393},[376,34753,34754,34757],{"class":378,"line":13543},[376,34755,34756],{"class":389},"            httpGet",[376,34758,394],{"class":393},[376,34760,34761,34764,34766],{"class":378,"line":13548},[376,34762,34763],{"class":389},"              path",[376,34765,411],{"class":393},[376,34767,17134],{"class":414},[376,34769,34770,34773,34775],{"class":378,"line":20989},[376,34771,34772],{"class":389},"              port",[376,34774,411],{"class":393},[376,34776,17144],{"class":560},[376,34778,34779],{"class":378,"line":20997},[376,34780,7452],{"class":1020},[376,34782,34783,34785,34787],{"class":378,"line":21002},[376,34784,472],{"class":389},[376,34786,411],{"class":393},[376,34788,34789],{"class":414},"v1\n",[376,34791,34792,34794,34796],{"class":378,"line":21007},[376,34793,482],{"class":389},[376,34795,411],{"class":393},[376,34797,34798],{"class":414},"Service\n",[376,34800,34801,34803],{"class":378,"line":21026},[376,34802,492],{"class":389},[376,34804,394],{"class":393},[376,34806,34807,34809,34811],{"class":378,"line":21031},[376,34808,499],{"class":389},[376,34810,411],{"class":393},[376,34812,17273],{"class":414},[376,34814,34815,34817,34819],{"class":378,"line":21045},[376,34816,7100],{"class":389},[376,34818,411],{"class":393},[376,34820,34473],{"class":414},[376,34822,34823,34825],{"class":378,"line":21071},[376,34824,509],{"class":389},[376,34826,394],{"class":393},[376,34828,34829,34831],{"class":378,"line":21084},[376,34830,17630],{"class":389},[376,34832,394],{"class":393},[376,34834,34835,34838,34840],{"class":378,"line":21100},[376,34836,34837],{"class":389},"    app",[376,34839,411],{"class":393},[376,34841,17273],{"class":414},[376,34843,34844,34847],{"class":378,"line":21105},[376,34845,34846],{"class":389},"  ports",[376,34848,394],{"class":393},[376,34850,34851,34853,34855,34857,34860],{"class":378,"line":21115},[376,34852,586],{"class":393},[376,34854,3602],{"class":389},[376,34856,411],{"class":393},[376,34858,34859],{"class":560},"80",[376,34861,34862],{"class":382},"                  # Service 對內的 Port\n",[376,34864,34865,34868,34870,34872],{"class":378,"line":21131},[376,34866,34867],{"class":389},"      targetPort",[376,34869,411],{"class":393},[376,34871,3607],{"class":560},[376,34873,34874],{"class":382},"          # 轉到容器的 Port\n",[376,34876,34877],{"class":378,"line":21136},[376,34878,7452],{"class":1020},[376,34880,34881,34883,34885],{"class":378,"line":21141},[376,34882,472],{"class":389},[376,34884,411],{"class":393},[376,34886,34887],{"class":414},"networking.k8s.io\u002Fv1\n",[376,34889,34890,34892,34894],{"class":378,"line":21159},[376,34891,482],{"class":389},[376,34893,411],{"class":393},[376,34895,34896],{"class":414},"Ingress\n",[376,34898,34899,34901],{"class":378,"line":21164},[376,34900,492],{"class":389},[376,34902,394],{"class":393},[376,34904,34905,34907,34909],{"class":378,"line":21175},[376,34906,499],{"class":389},[376,34908,411],{"class":393},[376,34910,17273],{"class":414},[376,34912,34913,34915,34917],{"class":378,"line":21199},[376,34914,7100],{"class":389},[376,34916,411],{"class":393},[376,34918,34473],{"class":414},[376,34920,34921,34923],{"class":378,"line":21204},[376,34922,509],{"class":389},[376,34924,394],{"class":393},[376,34926,34927,34930,34932],{"class":378,"line":21214},[376,34928,34929],{"class":389},"  ingressClassName",[376,34931,411],{"class":393},[376,34933,34934],{"class":414},"nginx\n",[376,34936,34937,34940],{"class":378,"line":21229},[376,34938,34939],{"class":389},"  rules",[376,34941,394],{"class":393},[376,34943,34944,34946,34949,34951],{"class":378,"line":21234},[376,34945,586],{"class":393},[376,34947,34948],{"class":389},"host",[376,34950,411],{"class":393},[376,34952,34953],{"class":414},"api.example.com\n",[376,34955,34957,34960],{"class":378,"line":34956},64,[376,34958,34959],{"class":389},"      http",[376,34961,394],{"class":393},[376,34963,34965,34968],{"class":378,"line":34964},65,[376,34966,34967],{"class":389},"        paths",[376,34969,394],{"class":393},[376,34971,34973,34975,34977,34979],{"class":378,"line":34972},66,[376,34974,7586],{"class":393},[376,34976,3592],{"class":389},[376,34978,411],{"class":393},[376,34980,34981],{"class":414},"\u002Fhouses\n",[376,34983,34985,34988,34990],{"class":378,"line":34984},67,[376,34986,34987],{"class":389},"            pathType",[376,34989,411],{"class":393},[376,34991,34992],{"class":414},"Prefix\n",[376,34994,34996,34999],{"class":378,"line":34995},68,[376,34997,34998],{"class":389},"            backend",[376,35000,394],{"class":393},[376,35002,35004,35007],{"class":378,"line":35003},69,[376,35005,35006],{"class":389},"              service",[376,35008,394],{"class":393},[376,35010,35012,35014,35016],{"class":378,"line":35011},70,[376,35013,34690],{"class":389},[376,35015,411],{"class":393},[376,35017,17273],{"class":414},[376,35019,35021,35024],{"class":378,"line":35020},71,[376,35022,35023],{"class":389},"                port",[376,35025,394],{"class":393},[376,35027,35029,35032,35034],{"class":378,"line":35028},72,[376,35030,35031],{"class":389},"                  number",[376,35033,411],{"class":393},[376,35035,35036],{"class":560},"80\n",[11,35038,35039],{},"套用與檢查：",[36,35041,35043],{"className":1006,"code":35042,"language":1008,"meta":44,"style":44},"kubectl apply -f house-api.yaml\n\nkubectl get pods -n realty -l app=house-api       # Pod 有沒有起來\nkubectl get endpoints house-api -n realty          # Service 有沒有找到 Pod\nkubectl describe pod \u003Cpod-name> -n realty          # 起不來時看 Events\nkubectl logs \u003Cpod-name> -n realty                  # 看應用程式的 Log\n",[15,35044,35045,35057,35061,35082,35101,35127],{"__ignoreMap":44},[376,35046,35047,35049,35052,35054],{"class":378,"line":379},[376,35048,1021],{"class":1020},[376,35050,35051],{"class":414}," apply",[376,35053,6926],{"class":560},[376,35055,35056],{"class":414}," house-api.yaml\n",[376,35058,35059],{"class":378,"line":386},[376,35060,1036],{"emptyLinePlaceholder":1035},[376,35062,35063,35065,35067,35070,35072,35074,35076,35079],{"class":378,"line":397},[376,35064,1021],{"class":1020},[376,35066,1024],{"class":414},[376,35068,35069],{"class":414}," pods",[376,35071,15063],{"class":560},[376,35073,17801],{"class":414},[376,35075,1074],{"class":560},[376,35077,35078],{"class":414}," app=house-api",[376,35080,35081],{"class":382},"       # Pod 有沒有起來\n",[376,35083,35084,35086,35088,35091,35094,35096,35098],{"class":378,"line":405},[376,35085,1021],{"class":1020},[376,35087,1024],{"class":414},[376,35089,35090],{"class":414}," endpoints",[376,35092,35093],{"class":414}," house-api",[376,35095,15063],{"class":560},[376,35097,17801],{"class":414},[376,35099,35100],{"class":382},"          # Service 有沒有找到 Pod\n",[376,35102,35103,35105,35107,35109,35112,35115,35118,35120,35122,35124],{"class":378,"line":418},[376,35104,1021],{"class":1020},[376,35106,1048],{"class":414},[376,35108,1071],{"class":414},[376,35110,35111],{"class":3196}," \u003C",[376,35113,35114],{"class":414},"pod-nam",[376,35116,35117],{"class":393},"e",[376,35119,15391],{"class":3196},[376,35121,15063],{"class":560},[376,35123,17801],{"class":414},[376,35125,35126],{"class":382},"          # 起不來時看 Events\n",[376,35128,35129,35131,35134,35136,35138,35140,35142,35144,35146],{"class":378,"line":429},[376,35130,1021],{"class":1020},[376,35132,35133],{"class":414}," logs",[376,35135,35111],{"class":3196},[376,35137,35114],{"class":414},[376,35139,35117],{"class":393},[376,35141,15391],{"class":3196},[376,35143,15063],{"class":560},[376,35145,17801],{"class":414},[376,35147,35148],{"class":382},"                  # 看應用程式的 Log\n",[11,35150,35151,35154,35155,35158],{},[15,35152,35153],{},"kubectl get endpoints"," 是我最常用的除錯指令：",[23,35156,35157],{},"如果這裡是空的，代表 Service 沒有找到任何就緒的 Pod","，要嘛 Label 對不上，要嘛 Pod 的 Readiness 一直沒通過。",[28,35160,35161],{"id":35161},"剛開始最常踩到的坑",[66,35163,35164,35172,35184,35196,35206,35212,35218,35224],{},[69,35165,35166,35169,35170],{},[23,35167,35168],{},"Label 對不上","：Service 的 Selector、Deployment 的 Selector、Pod Template 的 Label 三者要一致。請求一直失敗時，先看 ",[15,35171,35153],{},[69,35173,35174,55,35180,35183],{},[23,35175,35176,35177,35179],{},"使用 ",[15,35178,6559],{}," 標籤",[15,35181,35182],{},"image: house-api:latest"," 無法知道現在跑的是哪一版，也無法退版，而且映像檔在某些拉取策略下不會更新。每次建置都用明確的版本號或 Commit SHA",[69,35185,35186,35192,35193,35195],{},[23,35187,35188,35191],{},[15,35189,35190],{},"targetPort"," 和容器的 Port 不一致","：Service 的 ",[15,35194,35190],{}," 要對到應用程式實際監聽的 Port。.NET 的容器映像檔預設監聽 80，自己改成 8080 時兩邊都要改",[69,35197,35198,35201,35202,35205],{},[23,35199,35200],{},"Secret 不是加密","：Secret 的內容只是 ",[23,35203,35204],{},"Base64 編碼","，任何能讀取它的人都能還原。要控管誰能讀取 Secret，敏感資料也不要把 YAML 直接放進 Git",[69,35207,35208,35211],{},[23,35209,35210],{},"沒有設定 Resource Requests","：Scheduler 不知道 Pod 需要多少資源，可能把太多 Pod 塞到同一台 Node 上，大家一起變慢，甚至被系統砍掉",[69,35213,35214,35217],{},[23,35215,35216],{},"沒有 Readiness Probe","：容器一啟動就會被 Service 加進清單，應用程式還在初始化，請求就進來了，部署時會出現一波錯誤",[69,35219,35220,35223],{},[23,35221,35222],{},"把資料寫在容器裡","：上傳的圖片存在容器的資料夾，Pod 重建就全部消失，而且 3 個 Pod 各自只有一部分的檔案",[69,35225,35226,35229,35230,35233],{},[23,35227,35228],{},"Pod 一直重啟（CrashLoopBackOff）","：先 ",[15,35231,35232],{},"kubectl logs --previous"," 看上一次當掉前的 Log，通常是設定或連線字串錯誤",[28,35235,1890],{"id":1890},[11,35237,35238],{},"Deployment 透過 ReplicaSet 維持 Pod 的數量與版本，Service 用 Label Selector 找到就緒的 Pod 並提供固定的入口，Ingress 依網域與路徑把外部的 HTTP 請求分給不同的 Service，ConfigMap 與 Secret 讓同一個映像檔在不同環境使用不同的設定；物件之間全靠 Label 連結，請求不通時，先檢查 Endpoints 是不是空的。",[1132,35240,35241],{},"html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",{"title":44,"searchDepth":386,"depth":386,"links":35243},[35244,35245,35246,35247,35248,35249,35250,35251,35252,35253,35254,35255],{"id":34013,"depth":386,"text":34014},{"id":34044,"depth":386,"text":34044},{"id":34086,"depth":386,"text":34087},{"id":34135,"depth":386,"text":34136},{"id":34176,"depth":386,"text":34177},{"id":34221,"depth":386,"text":34222},{"id":34326,"depth":386,"text":34327},{"id":34351,"depth":386,"text":34352},{"id":34391,"depth":386,"text":34392},{"id":34425,"depth":386,"text":34426},{"id":35161,"depth":386,"text":35161},{"id":1890,"depth":386,"text":1890},"2022-01-11",{},{"title":34008,"description":44},"articles\u002Fk8s-core-objects",[1163,15504,1454,17902],"1ct01HRPwAB7bvpxWyutbTxIBQJwWcev2RZkj_WuVfI",{"id":35263,"title":35264,"body":35265,"date":36336,"description":44,"excerpt":1156,"extension":1157,"meta":36337,"navigation":1035,"path":36338,"seo":36339,"stem":36340,"tags":36341,"__hash__":36346},"articles\u002Farticles\u002Fmqtt-iot-basics.md","MQTT 在 IoT 的應用：Broker、Topic、QoS、Retained 與遺囑訊息一次搞懂",{"type":8,"value":35266,"toc":36322},[35267,35271,35278,35304,35311,35315,35321,35324,35341,35348,35355,35358,35362,35368,35374,35380,35443,35453,35457,35460,35518,35522,35528,35534,35562,35565,35606,35610,35621,35632,35652,35659,35663,35670,35695,35698,35702,35705,35726,35729,35754,35761,35765,35768,35804,35807,35813,35816,35822,35825,36048,36051,36160,36163,36244,36246,36314,36316,36319],[28,35268,35270],{"id":35269},"為什麼-iot-不適合直接用-http-控制設備","為什麼 IoT 不適合直接用 HTTP 控制設備",[11,35272,35273,35274,35277],{},"我之前的家電控制是這樣做的：每個 ESP8266 在內網開一個 HTTP 服務，數據機設定 Port Forwarding，手機從外網打 ",[15,35275,35276],{},"http:\u002F\u002F家裡的IP:Port\u002Fon"," 就能開燈。做出來是能用，但問題很多：",[66,35279,35280,35286,35292,35298],{},[69,35281,35282,35285],{},[23,35283,35284],{},"設備要對外開 Port","：只要知道 IP 和 Port，任何人都能控制，等於把家電直接暴露在網路上",[69,35287,35288,35291],{},[23,35289,35290],{},"沒有固定 IP 就不能用","：換到沒有固定 IP 的環境，或是在電信業者的 NAT 後面，外面根本連不進來",[69,35293,35294,35297],{},[23,35295,35296],{},"手機不知道設備現在的狀態","：要知道燈是開是關，只能一直輪詢每個設備",[69,35299,35300,35303],{},[23,35301,35302],{},"一對多很麻煩","：想讓「手機」和「排程程式」都知道澆花系統啟動了，就要各自打一次 API",[11,35305,35306,35307,35310],{},"MQTT（Message Queuing Telemetry Transport）就是為這種場景設計的協定。它最早是 IBM 在 1999 年為了透過衛星監控油管而設計的，目標就是",[23,35308,35309],{},"在頻寬小、網路不穩、設備資源有限","的環境下可靠地傳訊息，現在是 OASIS 標準（3.1.1 版），也是 IoT 領域最常見的通訊協定之一。",[28,35312,35314],{"id":35313},"mqtt-的基本模型所有人都連到-broker","MQTT 的基本模型：所有人都連到 Broker",[11,35316,35317],{},[1952,35318],{"alt":35319,"src":35320},"MQTT 架構示意圖：客廳燈、房間電腦、澆花系統三個 ESP8266 設備位於家中 NAT 後方，全部主動往外連到雲端的 MQTT Broker（Mosquitto，TLS 8883 埠）；設備訂閱自己的 set 主題接收指令，並發布 state 主題回報狀態；手機 App 與排程程式也連到同一個 Broker，App 訂閱 home\u002F+\u002F+\u002Fstate 取得所有設備狀態，並發布 home\u002Flivingroom\u002Flight\u002Fset 送出開燈指令；數據機不需要設定任何 Port Forwarding","\u002Fimages\u002Farticles\u002Fmqtt-architecture.svg",[11,35322,35323],{},"MQTT 採用**發布／訂閱（Publish／Subscribe）**模型，角色只有兩種：",[66,35325,35326,35331],{},[69,35327,35328,35330],{},[23,35329,5961],{},"：訊息的中轉站，負責接收所有發布的訊息，再轉送給有訂閱的人。常見的實作有 Mosquitto、EMQ X、HiveMQ",[69,35332,35333,35336,35337,35340],{},[23,35334,35335],{},"Client","：所有連到 Broker 的程式都是 Client，",[23,35338,35339],{},"同一個 Client 可以同時發布和訂閱","。ESP8266、手機 App、後端排程都是 Client",[11,35342,35343,35344,35347],{},"這個模型最大的好處是：",[23,35345,35346],{},"每個 Client 都是主動往外連到 Broker","。設備在 NAT 後面也沒關係，因為連線是它自己發起的，Broker 再透過這條已經建立的 TCP 連線把訊息推回去。數據機不用開任何 Port，前面說的問題一次解決大半。",[11,35349,35350,35351,35354],{},"另外，發布者和訂閱者",[23,35352,35353],{},"彼此不知道對方存在","，只知道 Topic：澆花系統只管發布「我開始澆水了」，有幾個人在聽、是誰在聽，它完全不需要知道。",[11,35356,35357],{},"MQTT 的封包也非常精簡，固定標頭最少只有 2 個 Byte，比 HTTP 動輒幾百 Byte 的 Header 輕量很多，對記憶體只有幾十 KB 的 ESP8266 很友善。",[28,35359,35361],{"id":35360},"topic用斜線分層的主題","Topic：用斜線分層的主題",[11,35363,35364,35365,35367],{},"Topic 是一個用 ",[15,35366,9492],{}," 分層的字串，例如：",[36,35369,35372],{"className":35370,"code":35371,"language":41,"meta":44},[39],"home\u002Flivingroom\u002Flight\u002Fset\nhome\u002Flivingroom\u002Flight\u002Fstate\nhome\u002Fgarden\u002Fwatering\u002Fstate\n",[15,35373,35371],{"__ignoreMap":44},[11,35375,5320,35376,35379],{},[23,35377,35378],{},"不需要事先建立","，發布時寫什麼就是什麼。訂閱時可以使用兩種萬用字元：",[132,35381,35382,35397],{},[135,35383,35384],{},[138,35385,35386,35389,35391,35394],{},[141,35387,35388],{},"萬用字元",[141,35390,150],{},[141,35392,35393],{},"範例",[141,35395,35396],{},"符合",[152,35398,35399,35421],{},[138,35400,35401,35405,35408,35413],{},[157,35402,35403],{},[15,35404,13474],{},[157,35406,35407],{},"剛好一層",[157,35409,35410],{},[15,35411,35412],{},"home\u002F+\u002Flight\u002Fstate",[157,35414,35415,2276,35418],{},[15,35416,35417],{},"home\u002Flivingroom\u002Flight\u002Fstate",[15,35419,35420],{},"home\u002Fbedroom\u002Flight\u002Fstate",[138,35422,35423,35427,35430,35435],{},[157,35424,35425],{},[15,35426,32027],{},[157,35428,35429],{},"零到多層，只能放在最後",[157,35431,35432],{},[15,35433,35434],{},"home\u002Fgarden\u002F#",[157,35436,35437,2276,35440],{},[15,35438,35439],{},"home\u002Fgarden",[15,35441,35442],{},"home\u002Fgarden\u002Fwatering\u002Fstate",[11,35444,9742,35445,8759,35447,14372,35449,35452],{},[15,35446,13474],{},[15,35448,32027],{},[23,35450,35451],{},"只能用在訂閱","，發布時一定要寫完整的 Topic。",[100,35454,35456],{"id":35455},"topic-怎麼設計","Topic 怎麼設計",[11,35458,35459],{},"Topic 設計好，後面的訂閱就會很省事。我的原則是：",[66,35461,35462,35471,35491,35507],{},[69,35463,35464,55,35467,35470],{},[23,35465,35466],{},"由大到小",[15,35468,35469],{},"{位置}\u002F{區域}\u002F{設備}\u002F{用途}","，越後面越細，這樣萬用字元才好用",[69,35472,35473,55,35476,35479,35480,35483,35484,35487,35488,35490],{},[23,35474,35475],{},"指令與狀態分開",[15,35477,35478],{},"...\u002Fset"," 是「請你變成這樣」，",[15,35481,35482],{},"...\u002Fstate"," 是「我現在是這樣」。手機發 ",[15,35485,35486],{},"set","，設備收到、執行完之後再發 ",[15,35489,25799],{},"。如果兩者共用一個 Topic，設備會收到自己發出的狀態，分不清是指令還是回報",[69,35492,35493,55,35499,35502,35503,35506],{},[23,35494,35495,35496,35498],{},"不要用 ",[15,35497,9492],{}," 開頭",[15,35500,35501],{},"\u002Fhome\u002Flight"," 的第一層會是一個空字串，容易和 ",[15,35504,35505],{},"home\u002Flight"," 搞混",[69,35508,35509,55,35514,35517],{},[23,35510,35495,35511,35498],{},[15,35512,35513],{},"$",[15,35515,35516],{},"$SYS\u002F"," 開頭的 Topic 是 Broker 保留用來發布自身狀態的（例如連線數）",[28,35519,35521],{"id":35520},"qos三種投遞保證","QoS：三種投遞保證",[11,35523,35524],{},[1952,35525],{"alt":35526,"src":35527},"MQTT 三種 QoS 的封包往返：QoS 0 只送一次 PUBLISH，不確認，最多一次，可能遺失；QoS 1 送 PUBLISH 後等待 PUBACK，沒收到就帶 DUP 旗標重送，至少一次，可能重複；QoS 2 經過 PUBLISH、PUBREC、PUBREL、PUBCOMP 四次往返，剛好一次，但成本最高","\u002Fimages\u002Farticles\u002Fmqtt-qos.svg",[11,35529,35530,35531,55],{},"MQTT 在每一則訊息上都可以指定 ",[23,35532,35533],{},"QoS（Quality of Service）",[66,35535,35536,35542,35556],{},[69,35537,35538,35541],{},[23,35539,35540],{},"QoS 0：最多一次（At most once）","。送出去就不管了，沒有確認，網路一斷就遺失。適合高頻率、掉一筆也無所謂的資料，例如每秒回報一次的溫度",[69,35543,35544,35547,35548,35551,35552,35555],{},[23,35545,35546],{},"QoS 1：至少一次（At least once）","。接收端要回 ",[15,35549,35550],{},"PUBACK","，沒收到確認就重送，所以",[23,35553,35554],{},"可能重複","。大部分的指令都用這個等級",[69,35557,35558,35561],{},[23,35559,35560],{},"QoS 2：剛好一次（Exactly once）","。透過四次往返確保不重複也不遺失，但延遲與負擔最大，在 IoT 設備上很少真的需要",[11,35563,35564],{},"有兩個容易誤會的地方：",[211,35566,35567,35577],{},[69,35568,35569,35572,35573,35576],{},[23,35570,35571],{},"QoS 是「每一段」各自的","：發布者到 Broker 是一段，Broker 到訂閱者是另一段。發布者用 QoS 1 發布，但訂閱者用 QoS 0 訂閱，Broker 轉送給它時就會",[23,35574,35575],{},"降級成 QoS 0","。實際的保證是兩者中比較低的那個",[69,35578,35579,35582,35583,35586,35587,2276,35589,35592,35593,35586,35596,35599,35600,35602,35603,35605],{},[23,35580,35581],{},"QoS 1 會重複","：設備要能承受同一個指令收到兩次。所以指令的內容要用「",[23,35584,35585],{},"變成什麼狀態","」（",[15,35588,31658],{},[15,35590,35591],{},"OFF","），不要用「",[23,35594,35595],{},"切換",[15,35597,35598],{},"TOGGLE","）——收到兩次 ",[15,35601,31658],{}," 還是開，收到兩次 ",[15,35604,35598],{}," 就變回關了",[28,35607,35609],{"id":35608},"retained-message新訂閱者馬上知道目前狀態","Retained Message：新訂閱者馬上知道目前狀態",[11,35611,35612,35613,35616,35617,35620],{},"一般的訊息，Broker 轉送完就丟了。手機 App 打開時才訂閱 ",[15,35614,35615],{},"home\u002F+\u002F+\u002Fstate","，要等到每個設備",[23,35618,35619],{},"下一次","發布狀態才知道燈是開是關。",[11,35622,35623,35624,35627,35628,35631],{},"發布時加上 ",[23,35625,35626],{},"Retain 旗標","，Broker 會把這個 Topic 的",[23,35629,35630],{},"最後一則訊息","保留下來，之後只要有人訂閱，就馬上收到這一則。所以：",[66,35633,35634,35643],{},[69,35635,35636,35642],{},[23,35637,35638,35639,35641],{},"狀態（",[15,35640,25799],{},"）用 Retained","：App 一打開就能拿到每個設備的最新狀態",[69,35644,35645,35651],{},[23,35646,35647,35648,35650],{},"指令（",[15,35649,35486],{},"）不要用 Retained","：否則設備每次重新連線都會再收到一次舊的指令，例如「三天前的開燈」",[11,35653,35654,35655,35658],{},"要清除一個 Topic 的 Retained Message，就發布一則",[23,35656,35657],{},"內容為空","的 Retained 訊息。",[28,35660,35662],{"id":35661},"clean-session離線期間的訊息要不要保留","Clean Session：離線期間的訊息要不要保留",[11,35664,35665,35666,35669],{},"Client 連線時會帶一個 ",[23,35667,35668],{},"Clean Session"," 旗標：",[66,35671,35672,35679],{},[69,35673,35674,35678],{},[23,35675,35676],{},[15,35677,18747],{},"：每次連線都是全新的，斷線期間的訊息全部不保留，重新連線後也要重新訂閱",[69,35680,35681,35686,35687,35690,35691,35694],{},[23,35682,35683,35685],{},[15,35684,5575],{},"（Persistent Session）","：Broker 會依 ",[23,35688,35689],{},"Client ID"," 記住這個 Client 的訂閱，斷線期間發給它的 ",[23,35692,35693],{},"QoS 1、QoS 2 訊息會先排隊","，重新連線後補送",[11,35696,35697],{},"對控制指令來說，要不要補送其實要想清楚：設備斷線 10 分鐘，回來後補收到「10 分鐘前的開燈指令」是合理的；但如果是「澆水 5 分鐘」這種有時效的指令，晚了 2 小時才執行反而有問題。我的做法是設備用 Persistent Session，但在指令裡帶上時間戳，設備發現指令太舊就丟掉。",[28,35699,35701],{"id":35700},"keep-alive-與遺囑訊息知道設備斷線了","Keep Alive 與遺囑訊息：知道設備斷線了",[11,35703,35704],{},"Wi-Fi 斷線、設備斷電時，TCP 連線不一定會馬上被發現是斷的。MQTT 用兩個機制處理：",[66,35706,35707,35717],{},[69,35708,35709,35712,35713,35716],{},[23,35710,35711],{},"Keep Alive","：Client 連線時宣告一個秒數（例如 30 秒），在這段時間內如果沒有其他封包，就送一個 ",[15,35714,35715],{},"PINGREQ","。Broker 超過 1.5 倍的時間都沒收到任何東西，就判定這個 Client 已經斷線",[69,35718,35719,35722,35723,3934],{},[23,35720,35721],{},"遺囑訊息（Last Will and Testament，LWT）","：Client 連線時先告訴 Broker：「",[23,35724,35725],{},"如果我不正常斷線，請幫我發布這則訊息",[11,35727,35728],{},"把兩者結合，就能做出設備的上下線狀態：",[211,35730,35731,35742,35749],{},[69,35732,35733,35734,35737,35738,35741],{},"設備連線時設定遺囑：Topic ",[15,35735,35736],{},"home\u002Flivingroom\u002Flight\u002Fstatus","、內容 ",[15,35739,35740],{},"offline","、Retained",[69,35743,35744,35745,35748],{},"連線成功後，自己發布一則 ",[15,35746,35747],{},"online"," 到同一個 Topic，也是 Retained",[69,35750,35751,35752],{},"設備斷電，Broker 在 Keep Alive 逾時後代為發布 ",[15,35753,35740],{},[11,35755,35756,35757,35760],{},"App 訂閱 ",[15,35758,35759],{},"home\u002F+\u002F+\u002Fstatus","，就能即時知道哪些設備離線了，而且因為是 Retained，打開 App 的當下就看得到。",[28,35762,35764],{"id":35763},"使用情境用-mqtt-改寫家電控制","使用情境：用 MQTT 改寫家電控制",[11,35766,35767],{},"架構改成這樣：",[66,35769,35770,35777,35788,35798],{},[69,35771,35772,35773,35776],{},"一台雲端主機跑 ",[23,35774,35775],{},"Mosquitto","，只開放 TLS 的 8883 埠，關閉匿名登入",[69,35778,35779,35780,35782,35783,3860,35785],{},"每個 ESP8266 用自己的帳號連到 Broker，訂閱自己的 ",[15,35781,35486],{}," Topic，發布自己的 ",[15,35784,25799],{},[15,35786,35787],{},"status",[69,35789,35790,35791,3860,35793,35795,35796],{},"手機 App 訂閱 ",[15,35792,35615],{},[15,35794,35759],{}," 顯示所有設備，按下按鈕時發布到對應的 ",[15,35797,35486],{},[69,35799,35800,35801],{},"自動澆花的排程程式也是一個 Client，時間到了就發布 ",[15,35802,35803],{},"home\u002Fgarden\u002Fwatering\u002Fset",[11,35805,35806],{},"Mosquitto 的設定：",[36,35808,35811],{"className":35809,"code":35810,"language":41,"meta":44},[39],"# mosquitto.conf\nlistener 8883\ncafile   \u002Fetc\u002Fmosquitto\u002Fcerts\u002Fca.crt\ncertfile \u002Fetc\u002Fmosquitto\u002Fcerts\u002Fserver.crt\nkeyfile  \u002Fetc\u002Fmosquitto\u002Fcerts\u002Fserver.key\n\nallow_anonymous false\npassword_file \u002Fetc\u002Fmosquitto\u002Fpasswd\nacl_file      \u002Fetc\u002Fmosquitto\u002Facl\n",[15,35812,35810],{"__ignoreMap":44},[11,35814,35815],{},"ACL 限制每個帳號只能碰自己的 Topic，避免其中一個設備的帳號被拿走後能控制全部：",[36,35817,35820],{"className":35818,"code":35819,"language":41,"meta":44},[39],"# acl：客廳燈只能讀自己的指令、寫自己的狀態\nuser livingroom-light\ntopic read  home\u002Flivingroom\u002Flight\u002Fset\ntopic write home\u002Flivingroom\u002Flight\u002Fstate\ntopic write home\u002Flivingroom\u002Flight\u002Fstatus\n\n# 手機 App 可以讀所有狀態、送所有指令\nuser mobile-app\ntopic read  home\u002F+\u002F+\u002Fstate\ntopic read  home\u002F+\u002F+\u002Fstatus\ntopic write home\u002F+\u002F+\u002Fset\n",[15,35821,35819],{"__ignoreMap":44},[11,35823,35824],{},"ESP8266 端使用 Arduino 的 PubSubClient 函式庫：",[36,35826,35830],{"className":35827,"code":35828,"language":35829,"meta":44,"style":44},"language-cpp shiki shiki-themes github-dark","#include \u003CESP8266WiFi.h>\n#include \u003CPubSubClient.h>\n\nWiFiClientSecure net;\nPubSubClient mqtt(net);\n\nconst char* SET_TOPIC    = \"home\u002Flivingroom\u002Flight\u002Fset\";\nconst char* STATE_TOPIC  = \"home\u002Flivingroom\u002Flight\u002Fstate\";\nconst char* STATUS_TOPIC = \"home\u002Flivingroom\u002Flight\u002Fstatus\";\n\nvoid onMessage(char* topic, byte* payload, unsigned int length) {\n  String cmd;\n  for (unsigned int i = 0; i \u003C length; i++) cmd += (char)payload[i];\n\n  \u002F\u002F 用「目標狀態」而不是「切換」，重複收到也不會出錯\n  if (cmd == \"ON\")  digitalWrite(RELAY_PIN, HIGH);\n  if (cmd == \"OFF\") digitalWrite(RELAY_PIN, LOW);\n\n  mqtt.publish(STATE_TOPIC, cmd.c_str(), true);  \u002F\u002F 回報狀態，Retained\n}\n\nvoid connectMqtt() {\n  while (!mqtt.connected()) {\n    \u002F\u002F Client ID 要唯一；最後四個參數是遺囑：Topic、QoS、Retain、內容\n    if (mqtt.connect(\"livingroom-light\", MQTT_USER, MQTT_PASS,\n                     STATUS_TOPIC, 1, true, \"offline\")) {\n      mqtt.publish(STATUS_TOPIC, \"online\", true);\n      mqtt.subscribe(SET_TOPIC, 1);\n    } else {\n      delay(5000);  \u002F\u002F 連不上就等一下再試，不要瘋狂重連\n    }\n  }\n}\n\nvoid setup() {\n  pinMode(RELAY_PIN, OUTPUT);\n  WiFi.begin(WIFI_SSID, WIFI_PASS);\n  net.setFingerprint(BROKER_FINGERPRINT);  \u002F\u002F 驗證 Broker 憑證\n  mqtt.setServer(BROKER_HOST, 8883);\n  mqtt.setCallback(onMessage);\n}\n\nvoid loop() {\n  if (!mqtt.connected()) connectMqtt();\n  mqtt.loop();  \u002F\u002F 處理收到的訊息與 Keep Alive，一定要一直呼叫\n}\n","cpp",[15,35831,35832,35837,35842,35846,35851,35856,35860,35865,35870,35875,35879,35884,35889,35894,35898,35903,35908,35913,35917,35922,35926,35930,35935,35940,35945,35950,35955,35960,35965,35970,35975,35979,35983,35987,35991,35996,36001,36006,36011,36016,36021,36025,36029,36034,36039,36044],{"__ignoreMap":44},[376,35833,35834],{"class":378,"line":379},[376,35835,35836],{},"#include \u003CESP8266WiFi.h>\n",[376,35838,35839],{"class":378,"line":386},[376,35840,35841],{},"#include \u003CPubSubClient.h>\n",[376,35843,35844],{"class":378,"line":397},[376,35845,1036],{"emptyLinePlaceholder":1035},[376,35847,35848],{"class":378,"line":405},[376,35849,35850],{},"WiFiClientSecure net;\n",[376,35852,35853],{"class":378,"line":418},[376,35854,35855],{},"PubSubClient mqtt(net);\n",[376,35857,35858],{"class":378,"line":429},[376,35859,1036],{"emptyLinePlaceholder":1035},[376,35861,35862],{"class":378,"line":437},[376,35863,35864],{},"const char* SET_TOPIC    = \"home\u002Flivingroom\u002Flight\u002Fset\";\n",[376,35866,35867],{"class":378,"line":447},[376,35868,35869],{},"const char* STATE_TOPIC  = \"home\u002Flivingroom\u002Flight\u002Fstate\";\n",[376,35871,35872],{"class":378,"line":531},[376,35873,35874],{},"const char* STATUS_TOPIC = \"home\u002Flivingroom\u002Flight\u002Fstatus\";\n",[376,35876,35877],{"class":378,"line":542},[376,35878,1036],{"emptyLinePlaceholder":1035},[376,35880,35881],{"class":378,"line":552},[376,35882,35883],{},"void onMessage(char* topic, byte* payload, unsigned int length) {\n",[376,35885,35886],{"class":378,"line":564},[376,35887,35888],{},"  String cmd;\n",[376,35890,35891],{"class":378,"line":575},[376,35892,35893],{},"  for (unsigned int i = 0; i \u003C length; i++) cmd += (char)payload[i];\n",[376,35895,35896],{"class":378,"line":583},[376,35897,1036],{"emptyLinePlaceholder":1035},[376,35899,35900],{"class":378,"line":597},[376,35901,35902],{},"  \u002F\u002F 用「目標狀態」而不是「切換」，重複收到也不會出錯\n",[376,35904,35905],{"class":378,"line":605},[376,35906,35907],{},"  if (cmd == \"ON\")  digitalWrite(RELAY_PIN, HIGH);\n",[376,35909,35910],{"class":378,"line":616},[376,35911,35912],{},"  if (cmd == \"OFF\") digitalWrite(RELAY_PIN, LOW);\n",[376,35914,35915],{"class":378,"line":624},[376,35916,1036],{"emptyLinePlaceholder":1035},[376,35918,35919],{"class":378,"line":635},[376,35920,35921],{},"  mqtt.publish(STATE_TOPIC, cmd.c_str(), true);  \u002F\u002F 回報狀態，Retained\n",[376,35923,35924],{"class":378,"line":5815},[376,35925,3381],{},[376,35927,35928],{"class":378,"line":7233},[376,35929,1036],{"emptyLinePlaceholder":1035},[376,35931,35932],{"class":378,"line":7243},[376,35933,35934],{},"void connectMqtt() {\n",[376,35936,35937],{"class":378,"line":7251},[376,35938,35939],{},"  while (!mqtt.connected()) {\n",[376,35941,35942],{"class":378,"line":7721},[376,35943,35944],{},"    \u002F\u002F Client ID 要唯一；最後四個參數是遺囑：Topic、QoS、Retain、內容\n",[376,35946,35947],{"class":378,"line":7729},[376,35948,35949],{},"    if (mqtt.connect(\"livingroom-light\", MQTT_USER, MQTT_PASS,\n",[376,35951,35952],{"class":378,"line":7740},[376,35953,35954],{},"                     STATUS_TOPIC, 1, true, \"offline\")) {\n",[376,35956,35957],{"class":378,"line":9569},[376,35958,35959],{},"      mqtt.publish(STATUS_TOPIC, \"online\", true);\n",[376,35961,35962],{"class":378,"line":9581},[376,35963,35964],{},"      mqtt.subscribe(SET_TOPIC, 1);\n",[376,35966,35967],{"class":378,"line":9590},[376,35968,35969],{},"    } else {\n",[376,35971,35972],{"class":378,"line":9595},[376,35973,35974],{},"      delay(5000);  \u002F\u002F 連不上就等一下再試，不要瘋狂重連\n",[376,35976,35977],{"class":378,"line":9600},[376,35978,5766],{},[376,35980,35981],{"class":378,"line":9605},[376,35982,15700],{},[376,35984,35985],{"class":378,"line":9615},[376,35986,3381],{},[376,35988,35989],{"class":378,"line":9639},[376,35990,1036],{"emptyLinePlaceholder":1035},[376,35992,35993],{"class":378,"line":9651},[376,35994,35995],{},"void setup() {\n",[376,35997,35998],{"class":378,"line":9656},[376,35999,36000],{},"  pinMode(RELAY_PIN, OUTPUT);\n",[376,36002,36003],{"class":378,"line":13527},[376,36004,36005],{},"  WiFi.begin(WIFI_SSID, WIFI_PASS);\n",[376,36007,36008],{"class":378,"line":13532},[376,36009,36010],{},"  net.setFingerprint(BROKER_FINGERPRINT);  \u002F\u002F 驗證 Broker 憑證\n",[376,36012,36013],{"class":378,"line":13543},[376,36014,36015],{},"  mqtt.setServer(BROKER_HOST, 8883);\n",[376,36017,36018],{"class":378,"line":13548},[376,36019,36020],{},"  mqtt.setCallback(onMessage);\n",[376,36022,36023],{"class":378,"line":20989},[376,36024,3381],{},[376,36026,36027],{"class":378,"line":20997},[376,36028,1036],{"emptyLinePlaceholder":1035},[376,36030,36031],{"class":378,"line":21002},[376,36032,36033],{},"void loop() {\n",[376,36035,36036],{"class":378,"line":21007},[376,36037,36038],{},"  if (!mqtt.connected()) connectMqtt();\n",[376,36040,36041],{"class":378,"line":21026},[376,36042,36043],{},"  mqtt.loop();  \u002F\u002F 處理收到的訊息與 Keep Alive，一定要一直呼叫\n",[376,36045,36046],{"class":378,"line":21031},[376,36047,3381],{},[11,36049,36050],{},"測試時用 Mosquitto 附的指令工具很方便：",[36,36052,36054],{"className":1006,"code":36053,"language":1008,"meta":44,"style":44},"# 監看所有設備的狀態（-v 會一起印出 Topic）\nmosquitto_sub -h broker.example.com -p 8883 --cafile ca.crt \\\n  -u mobile-app -P '***' -t 'home\u002F+\u002F+\u002Fstate' -v\n\n# 送出開燈指令\nmosquitto_pub -h broker.example.com -p 8883 --cafile ca.crt \\\n  -u mobile-app -P '***' -t 'home\u002Flivingroom\u002Flight\u002Fset' -m 'ON' -q 1\n",[15,36055,36056,36061,36085,36107,36111,36116,36135],{"__ignoreMap":44},[376,36057,36058],{"class":378,"line":379},[376,36059,36060],{"class":382},"# 監看所有設備的狀態（-v 會一起印出 Topic）\n",[376,36062,36063,36066,36069,36072,36074,36077,36080,36083],{"class":378,"line":386},[376,36064,36065],{"class":1020},"mosquitto_sub",[376,36067,36068],{"class":560}," -h",[376,36070,36071],{"class":414}," broker.example.com",[376,36073,14148],{"class":560},[376,36075,36076],{"class":560}," 8883",[376,36078,36079],{"class":560}," --cafile",[376,36081,36082],{"class":414}," ca.crt",[376,36084,6637],{"class":560},[376,36086,36087,36090,36093,36096,36099,36101,36104],{"class":378,"line":397},[376,36088,36089],{"class":560},"  -u",[376,36091,36092],{"class":414}," mobile-app",[376,36094,36095],{"class":560}," -P",[376,36097,36098],{"class":414}," '***'",[376,36100,15054],{"class":560},[376,36102,36103],{"class":414}," 'home\u002F+\u002F+\u002Fstate'",[376,36105,36106],{"class":560}," -v\n",[376,36108,36109],{"class":378,"line":405},[376,36110,1036],{"emptyLinePlaceholder":1035},[376,36112,36113],{"class":378,"line":418},[376,36114,36115],{"class":382},"# 送出開燈指令\n",[376,36117,36118,36121,36123,36125,36127,36129,36131,36133],{"class":378,"line":429},[376,36119,36120],{"class":1020},"mosquitto_pub",[376,36122,36068],{"class":560},[376,36124,36071],{"class":414},[376,36126,14148],{"class":560},[376,36128,36076],{"class":560},[376,36130,36079],{"class":560},[376,36132,36082],{"class":414},[376,36134,6637],{"class":560},[376,36136,36137,36139,36141,36143,36145,36147,36150,36152,36155,36158],{"class":378,"line":437},[376,36138,36089],{"class":560},[376,36140,36092],{"class":414},[376,36142,36095],{"class":560},[376,36144,36098],{"class":414},[376,36146,15054],{"class":560},[376,36148,36149],{"class":414}," 'home\u002Flivingroom\u002Flight\u002Fset'",[376,36151,13975],{"class":560},[376,36153,36154],{"class":414}," 'ON'",[376,36156,36157],{"class":560}," -q",[376,36159,15143],{"class":560},[11,36161,36162],{},"和原本的做法比較：",[132,36164,36165,36177],{},[135,36166,36167],{},[138,36168,36169,36171,36174],{},[141,36170],{},[141,36172,36173],{},"原本：HTTP + Port Forwarding",[141,36175,36176],{},"改成 MQTT",[152,36178,36179,36190,36201,36211,36222,36233],{},[138,36180,36181,36184,36187],{},[157,36182,36183],{},"連線方向",[157,36185,36186],{},"外面連進家裡",[157,36188,36189],{},"設備主動連出去",[138,36191,36192,36195,36198],{},[157,36193,36194],{},"需要固定 IP",[157,36196,36197],{},"需要",[157,36199,36200],{},"不需要（Broker 要有）",[138,36202,36203,36206,36208],{},[157,36204,36205],{},"驗證與加密",[157,36207,14375],{},[157,36209,36210],{},"帳號密碼 + TLS + ACL",[138,36212,36213,36216,36219],{},[157,36214,36215],{},"得知設備狀態",[157,36217,36218],{},"輪詢每個設備",[157,36220,36221],{},"訂閱 Retained 的 state",[138,36223,36224,36227,36230],{},[157,36225,36226],{},"得知設備離線",[157,36228,36229],{},"打不通才知道",[157,36231,36232],{},"遺囑訊息即時通知",[138,36234,36235,36238,36241],{},[157,36236,36237],{},"多個程式要收到同一個事件",[157,36239,36240],{},"各自呼叫",[157,36242,36243],{},"各自訂閱同一個 Topic",[28,36245,4494],{"id":4494},[66,36247,36248,36254,36263,36282,36288,36294,36300,36308],{},[69,36249,36250,36253],{},[23,36251,36252],{},"Client ID 一定要唯一","：兩個 Client 用同一個 Client ID 連線，Broker 會把先連的那個踢掉。如果兩個設備都設定了自動重連，就會互相踢來踢去、一直斷線。燒錄程式時最容易複製貼上忘了改",[69,36255,36256,36259,36260],{},[23,36257,36258],{},"ESP8266 的 PubSubClient 有限制","：發布只支援 QoS 0，訂閱支援 QoS 0 和 1；預設的封包大小上限是 128 Byte，訊息太長會直接發不出去，要調整 ",[15,36261,36262],{},"MQTT_MAX_PACKET_SIZE",[69,36264,36265,55,36274,36277,36278,36281],{},[23,36266,36267,36270,36271],{},[15,36268,36269],{},"loop()"," 裡不能有長時間的 ",[15,36272,36273],{},"delay()",[15,36275,36276],{},"mqtt.loop()"," 沒被呼叫，Keep Alive 就送不出去，Broker 會以為設備斷線並發布遺囑。需要等待的邏輯要改用 ",[15,36279,36280],{},"millis()"," 計時",[69,36283,36284,36287],{},[23,36285,36286],{},"TLS 在 ESP8266 上很吃記憶體","：TLS 握手需要不少 RAM，憑證驗證用 Fingerprint 比較省資源，但伺服器換憑證時，所有設備都要重新燒錄",[69,36289,36290,36293],{},[23,36291,36292],{},"重連要有間隔","：Broker 重啟時，所有設備會同時重連。每個設備的重連間隔最好加一點隨機值，不要全部在同一秒湧進來",[69,36295,36296,36299],{},[23,36297,36298],{},"不要什麼都用 QoS 2","：每則訊息四次往返，在不穩定的 Wi-Fi 上延遲會很明顯。溫度這種持續回報的資料用 QoS 0，指令用 QoS 1，再讓設備的處理邏輯能承受重複",[69,36301,36302,36307],{},[23,36303,36304,36305],{},"小心訂閱 ",[15,36306,32027],{},"：訂閱全部 Topic 在除錯時很方便，但在正式環境，Broker 會把所有訊息都推給這個 Client，設備一多流量就很可觀",[69,36309,36310,36313],{},[23,36311,36312],{},"Broker 是單點","：所有設備都依賴它，Broker 掛掉整個系統就停了。至少要讓 Mosquitto 開機自動啟動、異常自動重啟，並監控它的狀態",[28,36315,1890],{"id":1890},[11,36317,36318],{},"MQTT 讓 IoT 設備主動連到 Broker，用 Topic 發布與訂閱訊息，解決了設備在 NAT 後面、要對外開 Port 的問題；設計時把指令與狀態分成不同 Topic，狀態用 Retained 讓新訂閱者馬上拿到，指令用 QoS 1 並設計成重複收到也沒關係，再用 Keep Alive 加遺囑訊息掌握設備上下線，最後別忘了 TLS、帳號與 ACL，才不會從「任何人都能開你家的燈」變成「任何人都能訂閱你家的燈」。",[1132,36320,36321],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":44,"searchDepth":386,"depth":386,"links":36323},[36324,36325,36326,36329,36330,36331,36332,36333,36334,36335],{"id":35269,"depth":386,"text":35270},{"id":35313,"depth":386,"text":35314},{"id":35360,"depth":386,"text":35361,"children":36327},[36328],{"id":35455,"depth":397,"text":35456},{"id":35520,"depth":386,"text":35521},{"id":35608,"depth":386,"text":35609},{"id":35661,"depth":386,"text":35662},{"id":35700,"depth":386,"text":35701},{"id":35763,"depth":386,"text":35764},{"id":4494,"depth":386,"text":4494},{"id":1890,"depth":386,"text":1890},"2018-12-04",{},"\u002Farticles\u002Fmqtt-iot-basics",{"title":35264,"description":44},"articles\u002Fmqtt-iot-basics",[36342,36343,36344,35775,36345],"MQTT","IoT","ESP8266","發布訂閱","gLsA7H2eURzdS_Tlz941x7UGXpi6Nph7Nj8qTLfkivc",1792959149539]