所有筆記 · 發布於 2026-09-20
一個資料同步問題卡住我們十天
我們平常一天出一個版本,偶爾一天兩個。但 9 月 9 日的 1.61.0 之後,整整十天沒有任何新功能——只有六個修補版,和一批「App 打開就卡住」的回報。這篇不是「抱歉我們修好了」,而是那十天裡我們究竟找到了什麼:一個藏在「換一台裝置,進度還在」後面的設計錯誤,一段躺了很久、專挑練最久的人發作的舊程式,一條被 Android 自己拔掉的網路線,還有一個根本不是我們寫出來的當機。以及為什麼最後的答案不是把同步做得更聰明,而是做得更笨:一次只讓一台裝置寫。
我們出版本的速度是有點誇張的。從 8 月 16 日的 1.40.0 到 9 月 7 日的 1.60.0,二十三天裡出了二十一個功能版——平均一天一個,其中有五天一天出兩個。
然後 9 月 9 日的 1.61.0 之後,版號就停在 1.61 不動了。整整十天沒有任何新功能,只有六個接連補上去的修補版。
如果你有在看我們的更新紀錄,那十天的安靜大概是最明顯的異常。這篇就是那十天。
一個看起來很簡單的功能
「我在手機上背了三十個單字,晚上打開平板,那三十個字應該已經在那裡。」
大概沒有人會覺得這句話有什麼難的。你在手機上寫,雲端存一份,平板去拿。三個步驟,完。
如果世界上只有一台裝置,這確實就是全部。麻煩從第二台開始。
想像一本筆記本,你的所有進度都寫在裡面:哪個單字背過了、哪一題答錯了、連續練習幾天了。手機有一本抄本,平板有一本抄本,雲端還有一本。三本抄本,隨時可能有人在上面寫字——你早上在手機上答題時,平板可能還沒把昨晚的紀錄送上雲端;你在平板上把一個單字標成「已學會」,手機那本裡它還是「陌生」。
這時候「同步」要做的,就不是「拿一份下來」這麼簡單,而是把兩本各自被塗改過的抄本,逐行對照,合併成一本大家都同意的版本。哪一行是誰改的、什麼時候改的、兩邊都改了要聽誰的、一邊刪掉了另一邊卻加回來了又怎麼辦。
我們在 1.61.0 之前,就是這樣做的。而它做得不夠好。
從 8 月下旬到 9 月初,這類回報一直進來。三個星期裡有六封,全都是同一件事的不同說法:
「您好 我的平板跟手機都已經登入同一個帳號,網路也沒問題 但都無法同步」(9 月 2 日)
「跨裝置使用時,進度不統一。好幾天前在平板上學習的單字,今天換到手機上準備複習時,卻顯示該單字為新單字。」(9 月 5 日)
「我手機跟平板顯示要練習的目標還有分數不一樣」(9 月 7 日)
「在手機上解完任務,在平板上好像沒辦法連通?」(9 月 8 日)
漏掉的東西有一個共通點:它們都是一格一格的小東西——一個單字的熟悉度、今天的目標、一個剛解完的任務。整本筆記本確實有上傳,但某幾格在合併的時候,被另一台裝置手上的舊值蓋掉了。逐行對照的規則不夠細,分不出「這格是我剛改的」和「這格我根本沒動過」。
所以 1.61.0 要修的,是一個真實存在、而且有人正在為它寫信的問題。我們沒有修錯方向——我們是修得太用力。
我們把它做得「更聰明」,然後它就壞了
1.61.0 的改版,用意是讓兩台裝置同時使用時合併得更精確。方法是給筆記本裡的每一格都蓋上時間章:這格是哪台裝置、幾點幾分改的。刪掉的東西也不真的擦掉,只劃一條刪除線留在原地,免得另一台裝置把它抄回來。合併的規則寫了將近一千九百行。每一次上傳之前,先把整本筆記本重新對照一遍。
從工程角度,這是一套很完整的設計。問題出在它在哪裡跑。
手機裡負責畫面的那條線程——處理你每一次點擊、每一次捲動的那個店員——只有一個。這套新的同步機制,把「逐行對照兩本筆記本」的工作,也交給了同一個店員。筆記本小的時候沒事;但從 5 月上架就天天來報到的帳號,那本筆記本有一到三 MB,逐行對照要好幾秒。這幾秒之間,店員在低頭核帳,你按什麼他都沒空理。Android 有一條規矩:一個 App 超過五秒不回應觸控,就跳出「應用程式沒有回應」,建議你把它關掉。
上線第一天,這種「沒有回應」的紀錄有 169 筆。
更難堪的是,1.60.0 完全沒有這個問題。我們為了讓兩台裝置「合併得更好」,讓一台裝置的人卡住了。
有一件事幫了我們:1.61.0 是先上 Android 的。Google Play 的更新會比 App Store 早幾天到使用者手上,所以當那 169 筆冒出來的時候,iOS 版的 1.61.0 還躺在待上架的隊伍裡。我們當天就把它撤掉了。接下來要講的所有災情——凍結、六個修補版、換裝置要按接手——iPhone 和 iPad 的使用者一次都沒有碰到。他們安安穩穩停在 1.60.0,直到這一切收乾淨為止。
這不是什麼英明的決策,只是先出的那個平台替後出的那個踩了雷。但它確實讓我們有時間把問題想清楚,而不是一邊救兩個平台的火。
「那為什麼不直接退回 1.60.0?」
這是那兩天我們自己最想做的事。做不到,而且卡住我們的不只一層。
第一層:更新送出去就收不回來。 商店可以停止推送、可以擋下還沒下載的人,但已經更新的人不會自動退回去。所謂「退版」在實務上只能是「把舊的程式碼包成一個更大的版號再送一次」——那不是回到過去,那是一個行為跟舊版一樣的新版本。iOS 那邊我們來得及撤,正是因為它還沒送出去。
第二層:雲端那本筆記本已經換了格式。 一台 1.61.0 的裝置只要成功同步過一次,那個帳號在雲端的筆記本就被改寫成新格式——每一格多了時間章,刪掉的東西留著刪除線。這是每個帳號各自發生一次、而且回不去的升級。
第三層,真正擋住我們的:舊版看不懂新格式,但它不會承認。 1.60.0 的程式不知道時間章和刪除線的存在。它去上傳的時候,會把整本筆記本換成它自己認得的樣子——時間章被抹掉,刪除線被擦掉。結果是你刪掉的單字全部復活,而且整本帳簿從此失去「哪一格是誰、什麼時候改的」,另一台還在用新版的裝置再也無法正確合併。一台退回舊版的裝置,會把這個帳號在所有裝置上的進度一起弄壞。
所以新版上線之前,我們就先在雲端裝了一道門:一個帳號一旦升級成新格式,就不再接受舊版程式寫入進度。舊版還是可以讀、可以登出、可以刪帳號,就是不能寫。那道門本來是為了保護「有人裝了新版、有人還停在舊版」的過渡期——結果它也把我們自己的退路一起關上了。
而這正是重點:就算我們硬把 1.60.0 重新送出去,拿到它的人會發現 App 能開、能練習,但進度再也不會上雲端,而且完全沒有提示。這次改版的起點本來就是一批「手機跟平板對不上」的回報;再送一個「進度永遠上不去」的版本出去,等於把同一個問題放大十倍。
唯一的路是往前修。
五個修補版:越修越像在拆炸彈
接下來兩天(9 月 10 日到 11 日)我們出了 1.61.1 到 1.61.5,每一版都把一部分核帳工作從那個店員手上拿走、搬到後台。先是最明顯的一段,再來是整個合併,再來是「答完題按下一題」也不等存檔完成就先翻頁。
每一版都有效——凍結的紀錄從一天 169 筆掉到 44 筆——但每一版也都讓我們更清楚一件事:我們是在給一台越來越複雜的機器加潤滑油。合併規則還在,時間章還在,刪除線還在,逐行對照還在。它們只是被搬到看不見的地方跑而已。只要「兩台裝置可以同時寫」這個前提還在,這些機器就一件都拿不掉。
於是我們停下來問了一個更早該問的問題:真的需要兩台裝置同時寫嗎?
一本筆記本,只有一支筆
我們看了看實際的使用方式。絕大多數人只有一台裝置。有兩台的人,也幾乎都是「手機用完換平板」,不是「左手手機右手平板同時答題」。
那麼規則可以簡單到這樣:筆記本只附一支筆。誰拿著筆,誰可以寫。
- 打開 App、登入帳號,這台裝置先看筆在不在。在,就拿起來,開始練習。
- 把 App 切到背景(回主畫面、換別的 App),這台裝置先把筆放下,再把筆記本上傳。
- 另一台裝置打開,發現筆是空的,直接拿起來,先把最新的筆記本下載下來,然後練習。整個過程沒有任何提示——你只會覺得「進度都在」。
只有一種情況會看到提示:上一台裝置沒有把筆放下。它可能還開著、可能被手機強制關閉、可能在沒有網路的地方。這時候第二台會看到一個全螢幕的畫面:「這個帳號正在 Pixel 7 上使用」,還有一顆按鈕:接手使用。按下去,筆就到你手上,另一台裝置下次醒來會看到同樣的畫面,換它等。
一支筆的好處是:不需要合併。你拿到筆的時候,雲端那本就是最新版,直接抄下來;放下筆的時候,你手上這本就是最新版,直接抄上去。沒有時間章,沒有刪除線,沒有一千九百行規則。核帳這件事根本不存在,自然也不會卡住店員。
「那我搭十小時飛機呢?」
決定用一支筆之後,我們花最多時間的不是寫程式,而是想各種「這樣會不會把人卡死」的情境。幾個我們定下的規則:
拿著筆的人,離線多久都可以寫。 你在飛機上練了十小時,每一題都存在手機裡;落地連上網,一次上傳。筆從頭到尾都在你手上,沒有人能對你設限。
沒有筆的裝置,離線時不能開始練。 它需要連一次線,把筆拿到手。這是「嚴格模式」的代價,我們選了它——因為另一個選項是「先讓你寫,之後再合併」,而「之後再合併」正是我們剛剛花兩天拆掉的那顆炸彈。
筆會自己掉。 拿著筆的裝置,只要在前景、有網路,每五分鐘會跟雲端說一聲「我還在」。如果一台裝置兩小時都沒吭聲——手機掉了、壞了、被扔進抽屜——筆就視同放下,別的裝置可以直接拿,不用按接手。兩小時是一個折衷:短一點,飛機上的人落地前筆就被別人拿走;長一點,換手機的人要等太久。
接手不會弄丟另一邊的東西。 被接手的那台裝置,如果手上有還沒上傳的紀錄,它們還在那台裝置的磁碟裡。等它下次拿回筆,這是唯一還需要「合併」的情況——那一千九百行規則沒有被刪掉,只是退到這條小路上,而且在後台跑、沒有時限,快慢都無所謂。
規則很簡單,然後我們找到了八個洞
「一支筆」講起來兩句話。實作完之後,我們把程式碼交給另一個 AI(OpenAI 的 Codex)審查,它審了三輪,找到八個問題。每一個都對。挑三個講,因為它們很能說明「看起來簡單的規則,為什麼在電腦裡不簡單」。
放下筆的時候,筆可能已經不是你的了。 手機切到背景、上傳、放下筆——三個動作之間有幾百毫秒的空隙。如果平板剛好在這個空隙裡按了接手,手機那句「我把筆放下了」,放下的其實是平板的筆。修法:放下之前,先確認筆上刻的還是自己的名字。
接手之後忘了先抄。 第一版的接手,是拿到筆就把畫面解鎖,讓你直接練。但這台裝置手上的筆記本是舊的——它被擋在外面的期間,另一台寫的東西它一行都沒有。修法:拿到筆之後畫面繼續鎖著,多一行字「正在把最新進度帶到這台裝置」,抄完才放行。
它在等自己。 這個最像笑話。修完上面那個之後,新的流程變成:打開 App → 拿筆 → 拿到筆之後排一個「去抄最新版」的工作 → 抄完解鎖。但「拿筆」這件事,是被「同步」這個工作叫起來的;而「去抄最新版」也是一個同步工作,它要排隊等前面的同步做完。前面那個同步在等拿筆,拿筆在等抄寫,抄寫在等前面那個同步。三個人手牽手圍成一圈,每個人都在等下一個人。畫面上是一顆永遠轉不完的圈圈。修法:拿筆這件事只負責拿筆,抄寫的工作丟進隊伍就走,不站在那裡等。
另外五個,大同小異:下載失敗了不能偷偷放行、被別人拿走筆之後手機裡不能還留著「我上次是拿筆的人」的紙條、確認身分的那一瞬間有沒有可能被別人插隊……每一個單獨看都是「這也太巧了吧」的時機。但撞上的機會只跟一件事有關:同一個人的兩台裝置,換手的時候差了幾百毫秒。一個人一天換手一次,幾千個有兩台裝置的人加起來,再巧的時機每天都會有人撞上——而撞上的人不會覺得巧,只會覺得進度不見了。
第六個修補版:專挑練最久的人發作
一支筆還在做的時候,1.61.5 已經出去了,凍結也壓下來了。然後 9 月 15 日,我們從實際的執行紀錄裡看到一件不太對的事:那些慢的合併,有 94% 到 99.7% 的時間全花在同一本帳簿上——每天練習紀錄的那一本。其他所有帳簿加起來是 3 到 80 毫秒,它自己一個要 15.1 秒。
挖下去,這個洞跟 1.61.0 一點關係都沒有。它在更舊的程式裡,躺了很久。
合併兩本筆記本的時候要逐行比對,每一行都得問一句「這行是誰比較新?」。絕大多數時候兩邊的時間章不一樣,一眼就分出勝負。但如果時間章完全一樣——同一台裝置、同一毫秒——就得換個辦法決定順序:把兩邊的內容各自整份抄成標準格式,然後比字典序。
問題是,這句「誰比較新」被寫成了一個每次讀取都重算的算式,而每天練習紀錄的合併規則,每一筆紀錄都要讀它兩次。於是比對第 1 筆要整本重抄一遍,第 2 筆再一遍,第 2,400 筆再一遍。成本不是跟紀錄數成正比,是跟平方成正比。
一本三十天、2,400 筆的練習紀錄,合併要 11.7 秒;改成「每種帳簿只算一次」之後,18 毫秒。紀錄再多一倍(4,800 筆),原本是 47.4 秒,改完是 24 毫秒。
47 秒這個數字有一個很難看的後果:我們給後台的計算設了 30 秒上限,超過就放棄這次同步。所以練得越久、紀錄越多的人,越容易整次備份失敗——而且失敗是安靜的,他們只是不知道自己的進度從來沒有真的上雲端。最該被保護的那群人,被保護得最差。
收尾時還有一個更難堪的發現:那句「誰比較新」,其實永遠只會回答「是」。呼叫它的地方會先把勝方排在前面才交給規則,所以九個呼叫點裡「另一邊比較新」的那半段程式,從來沒有被執行過。我們把整段拿掉,改成在入口檢查「勝方真的排在前面」——故意把順序倒過來測一次,那個檢查在一輪測試裡叫了 79 次,證明它真的在看,不是一句好看的註解。
這個修補是 1.61.6(9 月 16 日,Android)。
Android 自己把網路拔掉
一支筆的規則裡最關鍵的一句是「切到背景就把筆放下」。真的拿兩台裝置測的時候,它幾乎從來沒發生過。
原因很笨,但完全不在我們手上:Android 在 App 切到背景之後,大約六秒內就把它的網路斷掉。我們原本的順序是「先上傳,成功了再放下筆」——因為上傳失敗還放下筆,等於把還沒送出去的紀錄丟在一台沒有筆的裝置上。結果上傳被斷網打斷,筆就一直放不掉;另一台裝置每次打開都看到「這個帳號正在其他裝置上使用」,每次都得按一下接手,剛好在五分鐘的接手冷卻時間內還得等。
一個設計成「你完全不會注意到」的功能,變成每次換裝置都要多按一顆按鈕。
修法是把順序倒過來:先放下筆,再跑去影印。放下筆只是改雲端上的一個小標記,幾百毫秒就做完,趕得上斷網;上傳如果來不及,紀錄還在手機裡,下次拿回筆的時候一起送。同時我們在筆上多刻一行字:「上一台還有沒送出的東西」。下一台裝置拿到筆、看到這行字,會在畫面上方顯示一句提醒,告訴你另一台裝置上還有進度沒上來——它不會消失,但你得回去開一下那台。
順手也補了一個一直讓人不安的空白:練習結束回到首頁時主動上傳一次,以及當真的有東西卡著上不去時,在右上角亮一個小圖示。這個圖示平常不會出現——一個永遠亮著的「同步狀態」只會教人忽略它,真的出事時反而沒人看。
一個不是我們寫出來的當機
最後一個,是這十天裡唯一一次真正的「App 當場消失」。
跟雲端要資料時,有些動作必須「一次做完,不能做一半」——先讀、再寫,中間不准別人插手。這種動作在手機裡的實作是:原生那一層開一個櫃台,通知我們的程式「可以開始問了」,然後站在櫃台等三十秒。三十秒到了就收攤。
兩個問題疊在一起才會出事。
第一,它是先開始數三十秒,再通知我們。如果手機當下正忙著別的事(比如,正在逐行比對一本很大的筆記本),我們收到通知時三十秒可能已經走掉一大半,而我們手上的碼表才剛歸零。第二,對著一個已經收攤的櫃台再問一句話,手機的回應不是「不好意思沒人」——是整個 App 當場被作業系統殺掉。那是原生層丟出來的錯誤,我們的程式攔不到,一行防護都插不進去。
這個洞不是 1.61 挖的。它在 1.60.0 就存在,兩個使用者踩到過,都在很慢的網路上。1.61 只是讓它更容易踩:新的同步在同一個三十秒裡要問三次,而且在第一次和第二次之間,插了一段把整本筆記本算一遍指紋的工作。帳號夠大、網路夠慢,第二個問題就問在收攤之後。
修法分兩步。第一步是把算指紋那段移到櫃台開門之前——它算的是手機上的東西,跟櫃台無關,本來就不該佔用那三十秒(它原本在重試時還會重算,一次同步最多算五遍)。第二步是給每一次發問加一個自己的期限:時間到了就不問,讓這次同步乾脆失敗、稍後重試,而不是讓 App 死掉。
我前兩個版本都是錯的,而且錯得很有教育意義。
第一版,我把碼表放在「收到通知之後」才開始跑——這等於完全沒防到上面第一個問題:手機忙了半分鐘才通知我們,碼表卻以為一切剛開始。第二版,我把櫃台等待的時間拉長,讓兩邊的時間差沒那麼容易撞上。審查的 AI 兩次都退回來,第二次講得很直白:把機率降低不等於把問題修好。
對的做法是把碼表放在開櫃台之前,並且讓它的期限比櫃台的等待短。這樣不論通知晚到多久、重試幾次,「還允許發問」的那一刻永遠早於「最早可能收攤」的那一刻——這不是比較不容易出事,是不可能出事。改完沒幾天,同一個當機的 Android 版本也出現在警報裡,兩個使用者,症狀一模一樣;因為原因已經查清楚,這次直接把剩下幾個還沒防到的入口一起補完。
為什麼是十天,不是兩天
每一個修法本身,多半一兩個小時就寫完了。吃掉十天的是另外兩件事。
第一件:版本送出去之後,我們要等好幾天才知道它有沒有用。
這類問題有一個很討厭的性質——在我們自己的手機上看不到。開發用的帳號是新的,筆記本很小,逐行對照幾十毫秒就跑完了;真正出問題的是練了好幾個月、紀錄堆得很厚的帳號,而且要在真實的網路、真實的機型、真實的使用節奏下才會撞上。我們能看清楚的只有一種東西:使用者裝置回報上來的統計——今天有幾次「沒有回應」、哪一段慢、慢在哪一本帳簿。
而那份統計不是即時的。版本送出去,要等商店推送、等人真的更新、等他們照平常的習慣用個幾天,數字才會累積到足以判斷「這次到底有沒有比較好」。1.61.5 是 9 月 11 日出的,我們一直到 9 月 15 日才從累積的紀錄裡看出「還有一本帳簿特別慢」——那就是隔天的 1.61.6。
所以這十天不是連續十天在修,而是「修一天、等四天、再修一天」。等待的那幾天也不能手癢:在上一版的數據回來之前又送一版,兩版的影響會混在一起,下一次就更看不懂了。急著修,反而會讓自己變瞎。
第二件:9 月 20 日是考試日。
一支筆的改版在 9 月中就差不多可以出了。但它動到的是每一台裝置的同步行為,而考前那一週,正是最多人每天打開 App 衝刺的時候。一個在那個時間點出包的同步改版,代價不是「有點不方便」,是有人考前最後一週的練習紀錄出問題。
所以我們自己把它按住,等考完再出。這是這十天裡唯一一個不是被逼的延遲——是我們選的。如果再來一次,我們還是會這樣選。
現在狀況
- 一支筆隨 1.62.0 出貨,排在 9 月 20 日的考試之後。這篇寫的是我們怎麼走到這裡,不是保證從此不再卡——1.61.0 上線前,我們也覺得那套設計沒問題。
- 舊的一千九百行合併規則還在程式裡,只是退到「接手之後」那條小路上。真正把它拆掉要等實際數據確認「沒有筆的裝置真的沒在寫」,那是下一步,還沒做。
- 網頁版現在也認得這支筆,規則一樣。但它仍然是「先上傳、再放下筆」——瀏覽器不會像 Android 那樣切斷背景分頁的網路,所以那個順序在網頁上沒有害處,只是兩邊還不完全一致。
- 只有一台裝置的人,什麼都不會變。你永遠是那個拿著筆的人。
- 有兩台裝置、而且習慣「手機沒關就直接開平板」的人,偶爾還是會看到「接手使用」——手機被強制關閉、或人在沒有網路的地方,筆就放不下來。這是我們刻意的選擇:多按一下,換掉整台機器卡住的風險。
- 那個「上傳卡住」的小圖示,我們在真的裝置上只驗到一半。要製造出「連得上網、但就是傳不上去」的情況並不容易,把網路整個關掉會連帶影響太多東西。
最後
一個「換台裝置進度還在」的功能,背後是六封「手機跟平板對不上」的回報、三本互相塗改的筆記本、一千九百行合併規則、一個五秒的期限、169 次卡住、六個修補版、一支筆、八個洞、一段平方成本的舊程式、一條被作業系統拔掉的網路線,和一個等在櫃台後面的三十秒。
這十天最反覆出現的一課,不是「要更小心」,而是我們每一次以為找到原因的時候,都只找到了最新的那一個。凍結看起來是新的合併機制造成的,於是我們搬了五版;搬完才發現最慢的那些帳號卡在一段很舊的程式裡。換裝置要按接手,看起來是我們的規則太嚴,其實是 Android 把網路拔了。而最嚴重的那個當機,根本不是 1.61 寫出來的——我們只是讓它更容易發生。
我們原本以為問題是「合併得不夠好」,答案是讓合併更聰明;最後發現問題是「為什麼要合併」,答案是讓它不需要發生。這種事在軟體裡很常見:最好的那行程式碼,往往是被刪掉的那一千九百行。
你可以在 單手刷990 的「設定」裡看到「使用中的裝置」是哪一台。這十天的每一個修補版,都是從你們寄來的信和 App 內的回報開始的——包括一位耐心錄影給我們看「按了良好卻不翻頁」的朋友。謝謝你們沒有放棄回報。問題回報請用 App 內的「意見回饋」,那裡的回報會帶上版本號,我們能直接對到是哪一版的哪一段。上一篇開發筆記:App 更新太頻繁了嗎?