Skip to main content

第19章:マージしやすいデータ設計①(カウンタ/加算)➕🔢

この章でできるようになること 🎯✨

  • 「上書き」だと競合で壊れる理由を、体感レベルで説明できる 😵‍💫
  • 「いいね数」「閲覧数」みたいなカウンタ系を、競合しにくい形(加算)で設計できる ➕
  • 最終的整合性の世界でありがちな「重複」「遅延」に強いカウンタを作れる 🧷🔁

加算カウンタ


19.1 まずは結論 🧠⚡

カウンタ系(いいね数・PV・在庫の増減ログなど)は、「いまの合計値」を上書きしないのが基本です 🙅‍♀️ 代わりに、「+1 した」「+3 した」みたいな“増分(デルタ)”で表現すると、後から集計・マージしやすくなります ➕📦


19.2 なぜ「上書き」が危ないの?😱🧨(lost update 体験)

例:いいね数を「合計値」で上書きする設計

  • いま DB のいいね数は 10 👍
  • Aさんがいいね → 11 にして保存したい
  • ほぼ同時に Bさんもいいね → 11 にして保存したい

タイムラインで見ると… 🕰️

  • Aさん:10 を読んだ → 11 を書いた
  • Bさん:10 を読んだ → 11 を書いた
  • 結果:本当は +2 なのに +1 しか増えてない(1回分のいいねが消えた!)💥

この「消えた!」が lost update(更新の取りこぼし) だよ〜😵‍💫


19.3 「加算」にすると何が嬉しい?🎁✨(競合に強い)

同じ状況でも、操作を「+1」みたいに表現すると…

  • Aさん:+1 を発生させた
  • Bさん:+1 を発生させた
  • あとでまとめて足す → 10 + 1 + 1 = 12 🎉

ポイントはこれ👇

  • 「+1」と「+1」は、順番が入れ替わっても結果が同じ(足し算は順序に強い)🔀✅
  • つまり、最終的整合性でありがちな「遅れて届く」「順番がズレる」に耐えやすい 💪⏳

さらに、CRDT(衝突なしで収束するデータ型)の世界でも、カウンタは代表例として整理されていて、**“各レプリカの増分を持って、マージ時にうまく合成する”**という考え方が出てくるよ 🧲✨ (dsf.berkeley.edu)


19.4 カウンタ設計:3つのレベル感 🧩📚

レベルA:同じDBに書くなら「原子的インクリメント」🗃️⚡

1つのDBにみんなが書くなら、DBが用意してる「増分更新」を使うのが王道だよ 👍 例:DynamoDB なら Update で ADD/SET を使って アトミックに増分できる(同時更新でも潰れにくい)🛡️ (AWS ドキュメント)

ただし…

  • 「分散して別々に書いて、あとでマージ」みたいな世界になると、それだけでは足りないことがある 🙃

レベルB:非同期(API受付→Worker反映)なら「デルタ(+1)イベント」📨🔁

  • APIは「+1してね」イベントを積む
  • Workerが後で集計して反映する
  • ここで大事なのは 重複に耐える(冪等) こと 🧷

レベルC:複数レプリカで書けるなら「G-Counterっぽい形」🧲🔢(おまけ)

「各レプリカが自分の増分だけ持つ」形にすると、マージが安定しやすいよ 🌱 状態ベースの G-Counter では、だいたいこんな感じ👇

  • 状態:レプリカID → カウント(Map)
  • 値:全部足し算
  • マージ:各レプリカIDごとに 大きい方を採用(pointwise max) してから足す この “max でマージ” が「増えた事実を失わない」コツ ✨ (soft.vub.ac.be)

19.5 ハンズオン:いいね数を「上書き」から「加算」へリファクタ 🛠️💖

ここからは「いいね」を題材に、競合しにくい形に直すよ〜!✨ (この章は “最小で動く” 版にして、次章以降で強化していく感じでOK👌)


① データ構造:Post と LikeDeltaEvent を作る 📦🧾

// apps/shared/src/types.ts

export type PostId = string;

export type Post = {
id: PostId;
title: string;

// ✅ 合計値は「結果」であって、更新の単位ではない
likes: number;

// 🧷 冪等性のため:処理済みイベントIDを覚える(最小構成)
processedEventIds: Set<string>;
};

export type LikeDeltaEvent = {
eventId: string; // 一意(UUIDなど)
postId: PostId;
delta: number; // ここでは +1 だけにしてもOK
happenedAt: number; // Date.now()
};

② まず悪い例:上書きAPI(消えるやつ)😱

「やっちゃいがち」な形(参考)👇 ※この形を後で直すよ!

// apps/api/src/bad_overwrite_example.ts

import { Post } from "../../shared/src/types";

const posts = new Map<string, Post>();

export function overwriteLikes(postId: string, newLikes: number) {
const p = posts.get(postId);
if (!p) throw new Error("post not found");

// ❌ 合計値を上書き
p.likes = newLikes;
}

この設計だと、同時更新で lost update しやすいのがポイント 🙃


③ 良い例:APIは「+1イベント」を積む 📨➕

// apps/api/src/event_queue.ts

import { LikeDeltaEvent } from "../../shared/src/types";

export const likeEventQueue: LikeDeltaEvent[] = [];

// 教材なので「最小のID生成」(本番はUUID推奨)
export function newEventId() {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
// apps/api/src/like_endpoint.ts

import { likeEventQueue, newEventId } from "./event_queue";
import { LikeDeltaEvent } from "../../shared/src/types";

export function requestLikeIncrement(postId: string) {
const ev: LikeDeltaEvent = {
eventId: newEventId(),
postId,
delta: 1,
happenedAt: Date.now(),
};

// ✅ ここでやるのは「更新」じゃなく「更新依頼(デルタ)」
likeEventQueue.push(ev);

return { accepted: true, eventId: ev.eventId };
}

ポイント👇

  • APIの役目は「受け取ったよ!」まで(A寄り)🧾✅
  • 反映は後で Worker がやる(最終的整合性)⏳

④ Worker:イベントを集計して反映(重複も防ぐ)🧷🔁

// apps/worker/src/worker.ts

import { likeEventQueue } from "../../api/src/event_queue";
import { Post, LikeDeltaEvent } from "../../shared/src/types";

const posts = new Map<string, Post>();

// デモ用:初期データ
posts.set("p1", {
id: "p1",
title: "カフェの新作☕",
likes: 0,
processedEventIds: new Set(),
});

function applyLikeEvent(p: Post, ev: LikeDeltaEvent) {
// 🧷 冪等:同じイベントがもう処理済みなら無視
if (p.processedEventIds.has(ev.eventId)) return;

p.likes += ev.delta; // ✅ 加算!
p.processedEventIds.add(ev.eventId);
}

export function tickOnce() {
// 🧪 故障っぽさ:たまに重複配達が起きた体で同じイベントを2回処理してみる
const ev = likeEventQueue.shift();
if (!ev) return;

const p = posts.get(ev.postId);
if (!p) return;

applyLikeEvent(p, ev);

// 10%で重複
if (Math.random() < 0.1) {
applyLikeEvent(p, ev);
}
}

export function getPost(postId: string) {
const p = posts.get(postId);
if (!p) throw new Error("post not found");
return { id: p.id, title: p.title, likes: p.likes };
}

ここで勝ち筋はこれ👇

  • 反映が遅れても OK(最終的整合性)⏳
  • 重複して届いても OK(冪等)🧷
  • 順番が多少ズレても「足し算」は基本強い 🔀✅

⑤ 動作チェック:同時に20回いいねしてみる 💥👍

“同時”を雑に再現するミニスクリプト(例)👇

// apps/devtools/src/spam_like.ts

import { requestLikeIncrement } from "../../api/src/like_endpoint";
import { tickOnce, getPost } from "../../worker/src/worker";

for (let i = 0; i < 20; i++) {
requestLikeIncrement("p1");
}

// Workerを回す(実際は常時動いてる想定)
for (let i = 0; i < 25; i++) {
tickOnce();
}

console.log(getPost("p1"));

期待する気持ち 🙏✨

  • いいねが 20 付近になる(重複の10%が入ると 20 以上になり得る)
  • 「あ、重複したら増えちゃうんだ!」って気づけたら大成功 🎉 → 次章以降で「重複しても増えない」方向に強化していくよ 🧷🔥

19.6 よくある落とし穴まとめ ⚠️😵‍💫

落とし穴1:デルタは強いけど「重複」には弱い 📨🌀

  • リトライで同じイベントが再配達されると、足し算は二重に増えちゃう
  • だから イベントIDで重複排除がほぼ必須 🧷

落とし穴2:「取り消し」(−1) を入れると難易度アップ 😮

  • +1 だけなら楽(Grow-only)
  • -1 も入るなら、設計を「増分と減分を分けて持つ」方向にすると整理しやすい(PN-Counter の発想)🧲➕➖ (soft.vub.ac.be)

19.7 AI(Copilot等)に頼むと強いプロンプト例 🤖💬

  • 「いいねの重複配達を再現するテストケースを10個作って。イベントID重複、順序逆転、遅延を混ぜて」🧪
  • 「processedEventIds を永続化するなら、どの層に置く?(Domain/Infra)案を3つ」🧠
  • 「“上書き”が危険な理由を、女子大生向けのたとえ話で3つ」🍩✨

19.8 まとめ 📝🎀

  • カウンタ系は 合計値の上書きより デルタ(加算) が強い ➕🔢
  • 最終的整合性で避けられない「遅延」「順序ズレ」「重複」に対して、設計で勝ちやすくなる 💪⏳
  • 次に強化するなら、重複しても増えないように「冪等キーの永続化」へ進むのが自然 🧷🔥

(参考:TypeScript は現在 5.9 系が最新リリースとして扱われていて、将来はネイティブ実装プレビューの流れも出てきてるよ〜⚡) (github.com)