標題有點高大上,是為了解決實際應用中的一個問題。做了一個Android應用,用於記錄日常消費賬單,開始是單機版的,我老婆說太low了,起碼要能看到彼此的消費情況吧。為此,我還專門寫了一套基於protobuf的RPC組件,用於網絡通信,http://www.cnblogs.com/zmkeil/p/5176758.html。
應用本身比較簡單,幾張簡單粗暴的UI,涵蓋了增、刪、改各種功能,外加一個后台service組件,用於上傳賬單,並同步他人賬單。也算是麻雀雖小五臟俱全吧,看幾張效果圖。代碼見https://github.com/zmkeil/MyBill,可以直接安裝使用,不想賬單被我偷窺的話,在配置中將服務器地址亂填即可。
要做的事情
言歸正傳,按照DBA的工作方式,數據庫同步的最簡單方法就是把一個庫上的所有操作,完完全全地在另一個庫上執行一遍,mysql的主從庫,就是利用binlog來復制所有的insert、update、delete等操作,來實現同步的,這其實也是一種增量同步的思想。借鑒這一思想,在這個應用中我們主要做兩個事情:
- 把自己的賬單操作上傳到服務端;
- 把別人的賬單操作從服務端同步下來。
這里為了把復雜度降到最低,我們只定義了兩種賬單操作:insert、update。在表中增加一個is_deleted字段,update該字段來實現刪除功能。
把握一個最基本的原則,服務端只負責記錄賬單操作,不保存任何客戶端的狀態(如已經同步了哪些操作等),換而言之,服務端只提供最基本的需求:1)有新操作來,我把操作寫到數據庫中,並且把該操作記錄下來,供別人同步;2)要同步別人的操作,那你需要提供從哪一條開始同步,最多同步多少條等操作。
接口預覽
首先看一下protobuf-rpc的service,非常簡單,只有一個接口。
package microbill; option cc_generic_services = true; message Record { enum Type { NEW = 0; UPDATE = 1; } required Type type = 1; required string id = 2; required fixed32 year = 3; required fixed32 month = 4; optional fixed32 day = 5; optional fixed32 pay_earn = 6; optional string gay = 7; optional string comments = 9; optional fixed32 cost = 10; } message BillRequest { required string gay = 1; // push self's records repeated Record records = 2; // pull other's records optional fixed32 begin_index = 3; optional fixed32 max_line = 4 [default = 10]; } message BillResponse { required bool status = 1; optional string error_msg = 2; repeated Record records = 4; } service BillService { rpc update(BillRequest) returns (BillResponse); }
Request有三方面信息:
- 當前用戶是誰
- 本次要上傳的賬單操作(可以為空,已經全部上傳完了)
- 需要同步的別人的賬單操作的起始index(是以 1,2,3… 這樣編號的,具體實現見后面),以及最多同步幾個操作(防止數據包過大)。
Response有兩方面信息:
- 本次上傳是否成功
- 如果別人有新操作的話,records中記錄了別人的新操作。
具體實現
下面來看具體的實現。android中當然是使用了sqlite來記錄賬單,服務端則采用mysql。應用本身不需要實時性,但要可靠,不能出現數據重復、缺失等。客戶端上程序運行的周期很短,用戶可能打開記錄一下就關閉了,而且網絡也不一定開啟。服務端的運行環境就相對穩定很多,程序可以一直運行(只要不crash),網絡也較穩定。重點要解決的問題:
- 客戶端如何知道哪些操作已經成功上傳了,還有那些操作等待上傳?
- 服務端接收到多個用戶上傳上來的操作,怎么保存、管理,才能方便地供別人來同步?
- 客戶端怎么記錄已經同步了別人的哪些操作?
第一個問題,主要針對第一個事情。這個比較簡單,純粹是客戶端上的事,不需要服務端配合。可以參考mysql的binlog思想,專門以一個records.txt文件來記錄所有的操作,格式如下:
index operate id
1、index從1開始,依次遞增,唯一標示該次操作。這樣就可以用另一個文件updated_index.txt來記錄已經上傳到了哪一條(是順序上傳的),這個文件非常簡單,只要記錄一個index即可。那么下次上傳時,首先根據updated_index.txt找到需要上傳的起始index,然后到records.txt中去找(直接seek到第index行即可),每次默認上傳2條操作。僅當response.status = true時,才更新updated_index.txt文件(即原index += 2)。這里有兩個問題:
- 日積月累,records.txt會不會很大,每次上傳都從頭開始seek會很耗性能。這里我按照月份分隔了,每個月單獨記一份文件。服務端就不會有這問題,因為數據是常住內存的。
- 有時候由於網絡問題,一次上傳已經到了服務端,服務端做了更新,但是response卻沒能正確回到客戶端,那么客戶端就不會更新updated_index.txt,下次會重復上傳這些操作記錄。沒關系,服務端入庫時做了去重(很簡單,只要使用primary key的特性即可);但是仍然記錄到操作列表中了,別的客戶端會下載到重復的操作,也沒關系,客戶端下載時也做了去重。哈哈!這里有點坑,是我老婆發現的。
2、operate記錄操作類型,如前所述,只有insert,update兩種操作
3、id記錄了本次操作的的對象(庫中的id字段值),如果按照binlog的話,應該記錄操作的數據(如cost,comment,day等),但那樣會比較復雜。所以只記錄了id值,然后再到庫中去反查具體的數據。這邊有個可優化點:對於update操作,可能只更新了一個字段,但這里會把所有字段全部填寫到request中。
- 補充說明下,這里的id是string類型的,格式“user_year_month_INT”,以用戶名、年、月和一個遞增的整數組成,這樣保證每個用戶的id不會相同。
第二、三個問題,主要針對第二個事情,實際上是客戶端和服務端配合,來達到多個客戶端間同步的目的。首先說明一下:服務端所有的賬單都記錄在一張表中,也是以id作為key值,如前所述,這個id值是不對重復的。
1、服務端按照用戶維度,對上傳上來的操作記錄進行管理。為每一個用戶准備一個隊列,隊列中的元素和客戶端上records.txt文件中的每條記錄類似。不同的是,這里記錄的是別人的操作:每當有用戶上傳新操作記錄來時,服務端首先將該操作寫庫,然后在所有其他用戶的隊列中增加上這條操作。
2、另外每個用戶准備一個文件(append模式打開),每條操作記錄寫到隊列之前,先寫到文件中。那么服務端重啟時,就可以從文件中恢復隊列了。
3、客戶請求到來時,首先取出其中的begin_index,max_line字段,然后到他自己的隊列中找,如果begin_index已經超過了隊列的長度,說明沒有新的更新;否則找出max_line條操作記錄,根據其中的id到庫中反查具體數據,填充response.records(同客戶端)。
- 這里有個問題,如果同時有很多用戶,那么除了自己,其他人都是混在一起的。由於只有我和我老婆兩個人用,這里就將就了;實際上,可以在隊列元素中,多加一個字段user,就可以區分開了。
4、客戶端用一個sync_index.txt文件,記錄下次要同步的別人的操作記錄index,初始為1,每次response.records不為空時,更新該值(+= respons.records.count())。
小結
剛開始想得很簡單,不過到現在前前后后快4個月了,呵呵~~ 總算現在有個比較OK的版本了,代碼不夠嚴謹,補了又補,功能還行。
記得剛開始寫RPC框架時,熱情高漲,每天下班寫到凌晨2、3點,那時候正好是最冷的時候,給自己點個贊。后來寫android,就比較拖沓了,和用戶操作直接相關的,會比較煩。
到此告一段落。
附:
RPC框架,http://www.cnblogs.com/zmkeil/p/5176758.html
服務端代碼,https://github.com/zmkeil/microbill-server.git
android代碼,https://github.com/zmkeil/MyBill.git