dimesum

Ana sayfa / Blog / Mühendislik

Mühendislik

Bakiyeleri tam tutan salt ekleme defteri

· 7 dakika okuma ·

Bölüşülen masraf bakiyelerini tam tutan salt ekleme defterinin mekanizması: sıfır toplamlı günlükler, düzeltme kayıtları ve commit sırasıyla gönderen bir outbox.

Artırdığınız bir bakiye, sapan bir bakiyedir. Dimesum hiçbir bakiyeyi artırmaz. Her masraf, kayıtları tam olarak sıfıra toplanan çift taraflı bir günlük yazar ve grup ekranınızdaki sayı, bu kayıtların üzerinde tek bir komutla silip yeniden kurabileceğimiz bir izdüşümdür.

Bu iddiayı sınayabilirsiniz. Paranın hareket etmesinin tek yolu salt ekleme yapılan bir günlükse, yanlış bir bakiye bir gizem olmaktan çıkar ve çalıştırabileceğimiz bir sorguya dönüşür.

Bakiye artırdığınız bir sayı değil, bir izdüşümdür

Dimesum'un defteri üç tabloya sahiptir: journals, postings ve gruba, üyeye ve para birimine göre anahtarlanan bir balances izdüşümü. Gerçeği kayıtlar taşır. Bir üye gruba para koyduğunda kayıt pozitif, değer tükettiğinde negatiftir, dolayısıyla bakiye sade bir SUM(amount_minor) değeridir. İzdüşüm yetki için değil, hız için vardır: günlüğün kendi transaction'ı içinde yazılır ve kayıtların eksiksiz olması onu atılabilir kılar.

İki katman aynı sıfırı doğrular ve hiçbiri tek başına yeterli değildir

Go tarafında buildPostings, kayıt kümesi sıfıra toplanmadıkça bir transaction açmayı reddeder. Postgres tarafında ertelenmiş bir kısıt tetikleyicisi, commit anında her günlük için SUM(amount_minor) = 0 koşulunu yeniden denetler, çünkü kayıtlar satır satır eklenir ve satır bazlı bir denetim her günlüğün ilk ayağını reddederdi. Assert, hesaplayıcıdaki bir hatayı yakalar. Tetikleyici, onu hiç çağırmayan bir yazıcıyı yakalar.

Üçüncü katman bir yetkilendirmedir. UPDATE, DELETE ve TRUNCATE, her iki tabloda ledger_app rolünden geri alınmıştır, böylece hatalı kod denese bile geçmişi yeniden yazamaz.

Salt ekleme yapılan bir defterin imkânsız kıldıkları, artırılan bir bakiye tablosuyla karşılaştırıldığında
Hata sınıfıArtırılan bakiye tablosuSalt ekleme defteri
Borçludan alındı, ödeyene hiç alacak yazılmadıSonsuza dek sessizce yanlışSıfır toplam denetimi başarısız olur, yazma reddedilir
Bir pay değişir, toplam değişmezBakiyeler sessizce saparGünlük dengesiz olarak commit edilemez
"Bakiyem neden 412 lira?"YanıtlanamazHer kuruş bir günlüğe kadar izlenir
Üretimde bir acil düzeltmeİzlenmeyen değişiklikTek yol yeni ve denetlenen bir günlüktür

Düzenleme bir ters kayıt yazar, asla UPDATE değil

Bir masrafı düzenlemek, eski sürümün üzerine hiçbir şey yazmaz. Defter, tek bir expense.amended olayını tüketir ve tek bir transaction içinde iki günlük yazar: değiştirilen sürümün ayak ayak tam olumsuzlaması olan bir EXPENSE_REVERSAL, ardından yenisi için taze bir EXPENSE. Bir silme, ters kayıttan sonra durur.

Tek transaction, iki günlük kadar önemlidir. Ters kayıt tek başına commit edilseydi, bir grup hâlâ borçlu olduğu bir masraf için kısa süreliğine hiçbir şey borçlu olmazdı.

Bir masraf düzenlemesi, sürüm birini olumsuzlayan bir EXPENSE_REVERSAL ve sürüm iki için yeni bir EXPENSE'i tek bir transaction içinde yazar; bakiye izdüşümü tüm kayıtların toplamıdır. tek transaction EXPENSE expense:7c1:v1 4 kayıt toplam = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal her ayak olumsuzlanmış toplam = 0 EXPENSE expense:7c1:v2 5 kayıt toplam = 0 bakiye izdüşümü = üye ve para birimi başına SUM(postings) atılabilir: evenly rebuild-balances onu temizler ve her kaydı yeniden oynatır
Düzenleme ekler: bir ters kayıt olumsuzladığı sürümü adlandırır ve yerine gelen onun yanına yazılır.

Her iki idempotency anahtarı da üretilmez, sürümden türetilir: sürüm için expense:<id>:v<n> ve olumsuzlama için önceki sürümün anahtarı artı :reversal. journals.idempotency_key UNIQUE'tir, böylece yeniden teslim edilen bir olay günlüklerini zaten orada bulur ve hiçbir şey yapmaz. En az bir kez teslimatı güvenli kılan şey türetmedir: bir yeniden deneme, ilk denemenin kullandığı anahtarı hesaplar.

Commit sırası filigranı, bir değişikliği masrafının ardında tutar

Dimesum'un yayımladığı her olay, iş yazımıyla aynı transaction içinde bir outbox tablosuna yazılır. Masraf commit edilirse, duyuru vardır. Geri alınırsa, duyuru da geri alınır. Defter, var olmayan bir masraftan asla haberdar olmaz.

Gönderim sırası daha zor olan yarıdır. Kimlikler UUIDv7'dir ve zamana göre sıralanır, ancak kimliğin ne zaman üretildiğini kodlarlar, transaction'ının ne zaman commit edildiğini değil. Kimlik sırasına göre okuyan bir aktarıcı, daha erken bir kimlik alıp daha sonra commit eden bir transaction'ı atlayabilir, böylece bir değişiklik, değiştirdiği masrafın önüne geçer.

İş yazımı ve outbox satırı tek bir transaction içinde commit edilir; ardından aktarıcı yalnızca eklenen transaction kimliği pg_snapshot_xmin filigranının altında olan outbox satırlarını, transaction kimliği sırasına göre gönderir. tek transaction INSERT expense satırı INSERT outbox satırı konu id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 yazıcı işlemde olay veri yolu defter tüketicisi pg_snapshot_xmin(pg_current_snapshot()). Üzerindeki satırlar, daha eskisi hâlâ çalışmadan commit eden transaction'larca yazıldı. Alttaki satır bir sonraki geçişi bekler. Kimliğe göre sıralamak önce 019a-1a8'i gönderirdi, böylece bir değişiklik değiştirdiği masraftan önce gelebilirdi. inserted_xid'e göre sıralamak bunu yapamaz: sonraki bir olayın xid'i daha büyüktür.
Outbox satırı iş yazımıyla birlikte commit edilir ve aktarıcı, filigranın altında transaction kimliği sırasına göre gönderir.

Çözüm bir sütun ve bir yüklemdir. Her outbox satırı inserted_xid xid8 DEFAULT pg_current_xact_id() taşır ve aktarıcı yalnızca WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()) satırlarını, o xid'e göre sıralı okur (bkz. PostgreSQL transaction kimliği işlevleri). Nedensel olarak sonraki bir olay her zaman daha büyük bir xid taşır, çünkü var olabilmek için önceki satırı okuması gerekmiştir.

Değişiklik tüketicisi bu sıralamaya güvenerek yaklaşmaz. Öncülünün günlüğü olmayan bir değişiklik, henüz yeniyken yeniden teslim edilir ve iki dakikalık bir tolerans aşıldığında bir insan için bekletmeye alınır.

Para int64 alt birimdir ve üs her zaman iki değildir

Her tutar, alt birimlerin bir int64 sayımı artı bir ISO 4217 kodudur, yani 1.234,56 rupi {Minor: 123456, Currency: "INR"} olur. Tam sayılar disiplinle değil, yapısı gereği kesindir: temsil edilebilir hiçbir değer yarım kuruş değildir, dolayısıyla hiçbir işlem sessizce böyle bir değer üretemez. Python yardımcı bileşeni kendi AST'sini okur ve float sözcüğü tutar modülünde geçiyorsa bir testi başarısız kılar.

Asıl tuzak, alt birimin yüzde bir olduğunu varsaymaktır. JPY'nin hiç alt birimi yoktur ve KWD'nin üç ondalığı vardır, dolayısıyla ana birimler arasında verilip alt birimlere uygulanan bir kur, on kat yanlış olur. 2.000 yeni 0,58 ile alın: naif çarpım 1160'tır, bu 11,60 rupi olarak okunur, oysa yanıt 1.160'tır.

WRITE_OFF beşinci günlük türüdür çünkü kayıtları bir ödeşmeninkiyle aynıdır

Bir alacak silme, bir ödeşmenin yazdığı aynı iki ayağı yazar: borçlu yükselir, alacaklı düşer, aynı tutarda. Bunu SETTLEMENT içine katlamak, tam da şekillerin örtüşmesi nedeniyle reddedildi. "Asha sana 500 lira ödedi" ile "Asha'nın 500 lirasını bağışladın" farklı gerçeklerdir ve ikisini birbirine karıştıran bir akış, kimse ödemediği hâlde birinin ödediğini söylerdi.

Böylece WRITE_OFF, 2026-08-21 tarihinde kendi ayaklarıyla birlikte günlük türü CHECK'ine katıldı. Onları adlandırmak işin yarısıydı, çünkü SETTLE_PAY'i yeniden kullanan bir alacak silme, her "gerçekte ne kadar ödendi" sorgusunu sessizce yanlış yapardı.

Dimesum defterindeki beş günlük türü ve her birinin yazdığı ayaklar
Günlük türüNe zaman yazılırAyaklar
EXPENSEOluşturulduğunda ya da yeni bir sürüm birinin yerine geçtiğindePAID, SHARE
EXPENSE_REVERSALDüzenlendiğinde ya da silindiğindeÖnceki günlüğün ayakları, olumsuzlanmış
SETTLEMENTBir geri ödeme bildirildiğindeSETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSALKarşı taraf itiraz ettiğindeHer iki ödeşme ayağı, olumsuzlanmış
WRITE_OFFBir alacaklı bir talepten vazgeçtiğindeWRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED

İki alt komut, değişmezleri bir cron işine dönüştürür

evenly verify-ledger, değişmezleri tüm şema üzerinde yeniden kanıtlar ve herhangi bir isabet olduğunda sıfırdan farklı çıkar. Raporunun dört alanı vardır ve hepsinin boş olması beklenir: sıfıra toplanmayan günlükler, sıfıra toplanmayan gruplar, yeniden hesaplanan bir SUM(postings) ile ayrışan izdüşüm satırları ve bir insanı bekleyen bekletmedeki olaylar. Tüm taramalar tek bir repeatable-read anlık görüntüsünü paylaşır, böylece doğrulama sırasında düşen bir günlük bir uyumsuzluk uyduramaz.

Her tarama, kimliğe olduğu kadar para birimine göre de gruplar ve bununla kaçınılan başarısızlık bir yanlış negatiftir. 500 rupilik bir kayıt ile eksi 500 yenlik bir kayıt, bir sorgu para birimi sütununu göz ardı ettiğinde sıfıra toplanır, böylece iki kat bozuk bir defter temiz görünürdü.

evenly rebuild-balances [group-id] hem onarım hem de tatbikattır. İzdüşümü temizler ve her satırı kayıtlardan yeniden hesaplar, canlı yazımın yaptığı gibi her birini o üyeyi hareket ettiren son günlükle damgalar. Özellik satır satır eşitliktir: bir yeniden kurma ile canlı izdüşüm uyuşmadığında, haklı olan defterdir.

Bunları şu sırada çalıştırın

Önce doğrula, sonra yeniden kur. Rapor, sapan her satır için saklanan bakiyeyi ve yeniden hesaplananı adlandırır ve bir yeniden kurma saklanan değerin üzerine yazar, dolayısıyla önce yeniden kurmak kanıtı yok eder.

Bakiyeyi türetilebilir yapın, sapma bir sorguya dönüşsün

Salt ekleme yapılan bir defter, ikinci günlüğünü ancak kanıtlayabildiğinizde hak eder, bu yüzden doğrulayıcıyı ve yeniden kurmayı, onlara ihtiyaç duyan özellikten önce inşa edin. Hiç kimsenin çalıştırmadığı bir yeniden kurma bir kaçış kapağı değil, bir umuttur. Bu hafta en riskli para izdüşümünüzü seçin, onu kaynaktan yeniden hesaplayan sorguyu yazın ve ikisi uyuşmadığında kendinize çağrı gönderin.

Sık sorulan sorular

Masraf paylaşım uygulamasında salt ekleme defteri nedir?

Salt ekleme defteri, her para olayını sıfıra toplanan kayıtlardan oluşan bir günlük olarak kaydeder ve hiçbirini güncellemez ya da silmez. Dimesum'da bir masraf, bir düzenleme, bir ödeşme, bir itiraz ve bir alacak silme, her biri yeni bir günlük ekler. Bakiyeler daha sonra kayıtlar toplanarak türetilir; böylece her kuruş, onu hareket ettiren olaya kadar izlenir.

Salt ekleme defteri düzenlenen bir masrafı nasıl işler?

Bir düzenleme, tek transaction içinde iki günlük yazar: özgün kaydı ayak ayak olumsuzlayan bir EXPENSE_REVERSAL, ardından yerine gelen sürümü taşıyan yeni bir EXPENSE. Hiçbir şey yeniden yazılmaz ve bir silme yalnızca ters kaydı yazar. Her iki günlük de masraf sürümünden türetilen idempotency anahtarları alır; böylece yeniden teslim edilen bir olay onları zaten orada bulur ve hiçbir şeyi değiştirmez.

Para neden float yerine tam sayı olarak saklanır?

Tam sayı alt birimler yapısı gereği kesindir, ikili kayan nokta ise 0,1'i tam olarak temsil edemez ve bir grubun geçmişi boyunca sapar. Dimesum her tutarı, alt birimlerin bir int64 sayımı artı bir ISO 4217 kodu olarak saklar. Üs para biriminden gelir: JPY'nin hiç alt birimi yoktur, dolayısıyla yüzde bir varsaymak 100 kat hatadır.

Bölüşülen masraf bakiyelerinin sapmadığını nasıl anlarsınız?

evenly verify-ledger komutunu çalıştırın; değişmezleri tüm şema üzerinde yeniden kanıtlar ve herhangi bir isabet olduğunda sıfırdan farklı çıkar. Sıfıra toplanmayan günlükleri, sıfıra toplanmayan grupları, yeniden hesaplanan bir SUM(postings) ile uyuşmayan izdüşüm satırlarını ve bekletmedeki olayları raporlar. Onu her gece cron ile çalıştırın ve bozuk bir izdüşümü evenly rebuild-balances ile onarın.

Alacak silme neden kendi günlük türüne ihtiyaç duyar?

Bir alacak silme, bir ödeşmeninkiyle birebir aynı ayakları yazar; günlük türünün farklı olması gerekmesinin nedeni tam da budur. 'Asha sana 500 lira ödedi' ile 'Asha'nın 500 lirasını bağışladın' ifadelerini yalnızca o sözcük ayırır ve ikisini karıştıran bir akış, kimse ödemediği hâlde birinin ödediğini söylerdi. Ayakları WRITE_OFF_FORGIVEN ve WRITE_OFF_GRANTED olarak adlandırılır, böylece ödeme sorguları doğru kalır.