สองบทที่แล้วพูดถึง realtime game server: process ที่รันแมตช์เดียวแบบสด ๆ อัปเดตทุกอย่างด้วยความถี่สูง และปฏิบัติต่อตำแหน่งที่ client รายงานมาเป็นแค่ "การเดา" ที่ต้องตรวจสอบและแก้ ไม่ใช่ความจริง บทนี้พูดถึงทุกอย่างที่เหลือที่เกมแบบ live ต้องมี ซึ่งมีเยอะมาก เวลาคุณ login เข้าเกม ซื้อ skin เช็ค leaderboard หรือถูกจับเข้าแมตช์ สิ่งเหล่านี้ไม่แตะ realtime game server เลยแม้แต่น้อย — มันวิ่งผ่านชุดโปรแกรมอีกชุดหนึ่งที่เรียกว่า backend (กลุ่มของ server-side services กับ database ที่คอยดูแลให้เกม live อยู่ได้นอกเหนือจากแมตช์ใดแมตช์หนึ่ง) คุณจะได้เรียนว่า service พวกนี้ทำอะไรบ้าง มันคุยกันเองและคุยกับ database ยังไง และกฎสองข้อที่ป้องกันไม่ให้ account กับกระเป๋าเงินของผู้เล่นหลักล้านคนกลายเป็นความโกลาหล: server เป็นเจ้าของความจริงเพียงหนึ่งเดียว และ request ที่ถูกส่งซ้ำต้องไม่มีวันเก็บเงินผู้เล่นซ้ำสอง
ลองเปิดเกม live-service บนมือถือแล้วสังเกตดูว่าเกิดอะไรขึ้นก่อนที่คุณจะได้เล่นจริง มันเช็คว่าคุณ login อยู่หรือเปล่า อาจจะโชว์ shop, daily quest ชุดหนึ่ง, leaderboard คุณกด "Find Match" แล้วรอต่อคิว ต่อเมื่อทุกอย่างนั้นเสร็จแล้วเกมถึงจะพาคุณเข้าแมตช์จริง — ซึ่งก็คือ realtime game server จากสองบทที่แล้ว ทุกอย่างก่อนหน้าจุดนั้นวิ่งผ่าน backend ทั้งหมด
backend ของเกม live ทั่วไปประกอบด้วยงานแยกกันหลายส่วน ส่วนใหญ่เป็นโปรแกรมคนละตัวกัน:
สองกล่องนี้มักถูกเขียนคนละแบบ deploy คนละแบบ และ scale คนละแบบ เพราะมันแก้ปัญหาคนละอย่างกัน ที่เหลือของบทนี้จะพูดถึงกล่องด้านขวา
ลองเทียบกันตรง ๆ โดยใช้สิ่งที่คุณรู้อยู่แล้วเรื่อง game server จากบทก่อน ๆ:
งานทั้งหมดของ game server คือความเร็ว: ส่งตำแหน่งอัปเดตออกไปให้ทันก่อนเฟรมถัดไป ทุกเสี้ยววินาที ตลอดที่แมตช์ยังดำเนินอยู่ พอแมตช์จบ ข้อมูลนั้นจะหายไปก็ได้ — ไม่มีใครต้องการตำแหน่งตอนกลางไฟต์ของคุณในวันพรุ่งนี้ ส่วน backend service มีงานตรงข้ามกัน: gold balance, item ที่ครอบครองอยู่ และ account ของผู้เล่นต้องยังถูกต้องอยู่ในอีกหนึ่งสัปดาห์ข้างหน้า หลังจาก server process รีสตาร์ทมาแล้วสิบกว่ารอบ และหลังจากที่ server เครื่องที่ตอบ request กลายเป็นคนละเครื่องไปแล้ว ความแตกต่างของงานนี้เองเป็นตัวกำหนดทุกอย่างที่เหลือในบทนี้ เริ่มจากวิธีที่ server สองแบบนี้คุยกัน
ในขณะที่ game server ส่ง binary update เร็ว ๆ ผ่าน UDP ตลอดเวลา backend service เกือบทั้งหมดคุยกันผ่าน HTTP (protocol เดียวกับที่ browser ใช้โหลดหน้าเว็บ) โดยใช้สไตล์ที่เรียกว่า REST (ข้อตกลงในการจัด HTTP request: URL บอกว่า กำลังทำอะไรกับอะไร ส่วน HTTP verb บอกว่า จะทำ action อะไร) ข้อมูลที่ส่งกันมักเป็น JSON (JavaScript Object Notation — format ข้อความล้วนสำหรับข้อมูลแบบมีโครงสร้าง ประกอบด้วยคู่ key: value ซึ่งภาษาโปรแกรมสมัยใหม่แทบทุกตัวอ่านเขียนได้)
// C# -- a very small REST call using HttpClient
using System.Net.Http;
HttpClient http = new HttpClient();
http.BaseAddress = new Uri("https://api.mygame.com/");
// GET /shop/items --> ask the Shop Service what is currently for sale
HttpResponseMessage response = await http.GetAsync("shop/items");
string body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
ผลลัพธ์ที่คาดว่าจะได้ (ข้อความล้วน พิมพ์ออก console):
[
{ "id": "sword_01", "name": "Iron Sword", "price": 100 },
{ "id": "potion_01", "name": "Health Potion", "price": 20 }
]
สังเกตว่า client ไม่ต้องรู้เลยว่า Shop Service เขียนด้วย C#, Go หรือภาษาอะไร — JSON คือข้อความล้วน ๆ ที่อ่านได้ในภาษาไหนก็ได้ทั้งสองฝั่ง REST มีข้อตกลงเรื่อง verb ที่ทำให้ request คาดเดาได้: GET อ่านข้อมูลโดยไม่เปลี่ยนอะไร POST สร้างสิ่งใหม่ (การซื้อ, การสมัคร account) PUT/PATCH อัปเดตของที่มีอยู่แล้ว และ DELETE ลบมันทิ้ง URL บอกว่า กำลังทำอะไร ("shop/items") ส่วน verb บอกว่า จะทำอย่างไรกับมัน ("อ่านมัน")
/shop/items หรือ /player/42/inventory เรียกว่า endpoint (URL หนึ่งจุดที่ backend service รับ request ได้) backend จริง ๆ มักมี endpoint แบบนี้เป็นสิบ ๆ อัน — หนึ่งอันต่อหนึ่ง action ที่ client ทำได้การ login คือ REST call แรกที่แทบทุก session ทำ และมันวางรากฐานให้ทุก call ถัดไปพึ่งพา client ส่ง username กับ password ไปครั้งเดียว server เช็คกับ database แล้วถ้าตรงกัน จะส่ง token กลับมา (หลักฐานว่า request นี้มาจาก player ที่ผ่านการยืนยันตัวตนแล้วจริง ๆ เพื่อที่ server จะได้ไม่ต้องเช็ค password ซ้ำทุกครั้งที่มี call เข้ามา)
token มีสองแบบหลัก ๆ ที่ใช้กันบ่อย session ID คือ string สุ่ม ๆ ที่ server เก็บไว้ในตารางค้นหา (มักเก็บใน cache ที่เร็ว ๆ — section 12 จะพูดถึง Redis) ทุก request จะเอา ID ไปค้นหาว่าเป็นของ player คนไหน ส่วน JWT (JSON Web Token — token ที่บรรจุ player id กับเวลาหมดอายุไว้ในตัวมันเอง เซ็นด้วย cryptographic signature เพื่อไม่ให้ปลอมได้) ไม่ต้องค้นหาอะไรเลย: service ไหนก็ตรวจ signature แล้วอ่าน player id ออกมาจาก token ได้โดยตรง
// Pseudocode -- Auth Service login handler
public LoginResult HandleLogin(string username, string password)
{
PlayerRow row = db.QueryOne(
"SELECT id, password_hash FROM players WHERE username = @u", username);
if (row == null || !PasswordHasher.Verify(password, row.password_hash))
return LoginResult.Fail("invalid username or password");
string token = TokenService.CreateToken(row.id, expiresIn: TimeSpan.FromHours(4));
return LoginResult.Ok(token);
}
ผลลัพธ์ที่คาดว่าจะได้: username/password ที่ถูกต้องจะได้ token string ใช้งานได้ 4 ชั่วโมง ส่วน password ผิดหรือ username ไม่มีอยู่จริง ทั้งสองกรณีจะได้ error message เดียวกันเป๊ะ ๆ
บทก่อน ๆ สอนเรื่อง server-authoritative design สำหรับการเคลื่อนที่: game server ไม่ใช่ client เป็นคนตัดสินว่า player ไปจบที่ตำแหน่งไหนจริง ๆ กฎเดียวกันเป๊ะใช้กับกระเป๋าเงินของคุณด้วย request ควรอธิบาย ความตั้งใจ ("ฉันอยากซื้อสิ่งนี้") ไม่ใช่ ค่าสุดท้าย ("ตั้งค่า gold ของฉันเป็นเท่านี้") — เพราะใครก็ตามสามารถดักและแก้ไข request ได้ด้วย proxy tool ฟรี ๆ ก่อนที่มันจะไปถึง server ของคุณ
// BAD -- the client just tells the server the new values directly.
// Anyone with a network proxy can change these numbers before sending.
POST /player/update
{ "playerId": 42, "gold": 999999, "inventory": ["legendary_sword"] }
ถ้า handler ของ request นั้นแค่เขียนอะไรก็ตามที่ได้รับลง database ตรง ๆ player ทุกคนที่เจอ request นี้ก็ทำตัวเองให้รวยได้ด้วย request ที่แก้ไขเพียงครั้งเดียว ทางแก้คือรับแค่ "ความตั้งใจ" เท่านั้น แล้วคำนวณตัวเลขทุกตัวที่เป็นผลลัพธ์บน server:
// GOOD -- the client only describes what it wants to happen.
POST /shop/buy
{ "playerId": 42, "itemId": "sword_01" }
// Server-side handler -- the ONLY place price and gold are decided.
public BuyResult HandleBuy(int playerId, string itemId)
{
Item item = catalog.GetItem(itemId); // price lives on the server
Player player = db.GetPlayer(playerId); // current gold lives on the server
if (player.Gold < item.Price)
return BuyResult.Fail("not enough gold");
player.Gold -= item.Price;
player.Inventory.Add(item.Id);
db.Save(player);
return BuyResult.Ok(player.Gold);
}
ผลลัพธ์ที่คาดว่าจะได้: request ของ client ไม่มี price และไม่มี gold amount เลยแม้แต่น้อย — handler ไปค้นหาทั้งสองอย่างเองจาก catalog กับ database ดังนั้นผลลัพธ์ไม่ได้ขึ้นอยู่กับอะไรที่ client ส่งมาเลย นอกจาก item ไหน ที่ถูกขอ
"price": 100 มาพร้อมกับ item id แล้วเชื่อมันตรง ๆ แทนที่จะไปค้นราคาจาก catalog ของ server เอง field นั้นแก้ไขได้ง่ายพอ ๆ กับค่า gold ตรง ๆ — client ที่ถูกแก้ไขสามารถส่ง "price": 1 สำหรับ item ระดับ legendary ได้ อะไรก็ตามที่กำหนดราคา, รางวัล, หรือ stat ต้องถูกค้นหาฝั่ง server เสมอ ห้ามอ่านมาจาก requestถ้า gold ของ player อยู่แค่ใน memory ของ server process เดียว การรีสตาร์ท process นั้น — ไม่ว่าจะเป็น deploy ปกติ, crash, หรือ maintenance ตามกำหนด — จะลบมันทิ้งไปเลย และเกมที่คนเล่นเยอะ ๆ ก็รัน backend service แต่ละตัวหลายชุดพร้อมกันอยู่แล้ว (section 11) ดังนั้น request ถัดไปอาจไปตกที่ process คนละตัวที่ไม่เคยเห็น player คนนั้นมาก่อนเลย ทางแก้คือเก็บสำเนาความจริงเพียงชุดเดียวของข้อมูล player แต่ละคนไว้ในที่ที่ process ทั้งหมดแชร์กันได้: database (โปรแกรมที่ทำหน้าที่เก็บข้อมูลอย่างถาวรบนดิสก์ และให้โปรแกรมอื่น ๆ หลายตัวอ่านเขียนมันได้อย่างปลอดภัยพร้อม ๆ กัน)
"บนดิสก์" สำคัญด้วยเหตุผลที่คุณรู้อยู่แล้วจากบท C: RAM เร็วแต่ว่างเปล่าทุกครั้งที่รีสตาร์ท ในขณะที่ disk storage รอดจากไฟดับและการรีสตาร์ท process ได้ database ให้ทั้งความคงทน (durability) นี้ บวกกับการเข้าถึงพร้อมกันอย่างปลอดภัย โดยที่คุณไม่ต้องเขียนปัญหายาก ๆ สองอย่างนี้เอง
database แบ่งกว้าง ๆ ได้เป็นสองตระกูล SQL database (เรียกอีกอย่างว่า relational — ตัวอย่าง: PostgreSQL, MySQL, SQL Server) เก็บข้อมูลเป็นตาราง (table) ที่มี column ตายตัว โดย row ในตารางต่างกันเชื่อมกันผ่าน key ที่แชร์กัน NoSQL database (คำเรียกรวม ๆ ที่หลวมกว่า — ตัวอย่าง: MongoDB, DynamoDB, Redis) เก็บข้อมูลแบบหลวม ๆ กว่านั้น มักเป็น document ทั้งก้อนที่รูปร่างต่างกันได้ในแต่ละ entry หรือเป็นคู่ key-value ง่าย ๆ
SQL คุ้มค่าเวลาข้อมูลมีโครงสร้างจริง ๆ ที่ต้องถูกต้องตลอด: player เป็นเจ้าของ item, item มีราคา, การซื้อหนึ่งครั้งเชื่อม player คนหนึ่งกับ item ชิ้นหนึ่ง — พอดีกับความสัมพันธ์แบบที่ section 8 กับ 9 จะต่อยอด NoSQL คุ้มค่าเวลาข้อมูลเปลี่ยนรูปร่างบ่อย ๆ ไม่ต้องการความสัมพันธ์ข้ามตารางที่เข้มงวด หรือต้องการความเร็วดิบสูงมากสำหรับการค้นหาแบบง่าย ๆ — session token, chat log, entry บน leaderboard
schema (ชุดของตารางกับ column ที่ database ถูกจัดวางไว้) สำหรับเกมเล็ก ๆ อันหนึ่งอาจหน้าตาแบบนี้ — สามตาราง หนึ่งสำหรับ player หนึ่งสำหรับ catalog item และอีกหนึ่งเชื่อมสองอันนี้เข้าด้วยกัน:
CREATE TABLE players (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
gold INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
price INTEGER NOT NULL
);
CREATE TABLE inventory (
player_id INTEGER NOT NULL REFERENCES players(id),
item_id TEXT NOT NULL REFERENCES items(id),
quantity INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (player_id, item_id)
);
primary key คือ column (หรือหลาย column) ที่ระบุตัวตน row แต่ละแถวอย่างไม่ซ้ำใคร — ไม่มี row สอง row ใน players ที่มี id ซ้ำกันได้ REFERENCES ประกาศ foreign key: คำสัญญาว่าค่า inventory.item_id ต้องตรงกับ row จริง ๆ ที่มีอยู่แล้วใน items ทำให้คุณบันทึกการครอบครอง item ที่ไม่เคยถูกกำหนดไว้เลยไม่ได้โดยไม่ตั้งใจ
ถ้าจะแสดงทุกอย่างที่ player คนหนึ่งครอบครอง พร้อมชื่อและราคาที่อ่านออกแทนที่จะเป็นแค่ id ดิบ ๆ query ต้อง join สองตารางเข้าด้วยกัน:
SELECT items.name, items.price, inventory.quantity
FROM inventory
JOIN items ON items.id = inventory.item_id
WHERE inventory.player_id = 42;
ผลลัพธ์ที่คาดว่าจะได้ (ตารางผลลัพธ์):
name | price | quantity
--------------+-------+---------
Iron Sword | 100 | 1
Health Potion | 20 | 3
JOIN รวม row จากสองตารางเข้าด้วยกันทุกที่ที่ key ตรงกันทั้งสองฝั่ง — ในที่นี้คือทุกที่ที่ item_id ของ row ใน inventory เท่ากับ id ของ row ใน items ถ้าไม่มี JOIN handler จะต้อง query แยกทีละ item เพื่อหาชื่อกับราคา รอบเดียวต่อหนึ่ง round trip
การซื้อ item จริง ๆ แล้วคือการเขียนสองครั้งแยกกัน: หัก gold ออกจาก players และเพิ่ม row เข้า inventory ถ้า server crash หรือ database connection หลุดพอดีระหว่างการเขียนสองครั้งนั้น gold ของ player หายไปแล้วแต่ item ไม่เคยมาถึง transaction (กลุ่มของการเขียน database ที่ database รับประกันว่าจะเกิดขึ้นทั้งหมด หรือไม่เกิดขึ้นเลยสักอย่าง — ไม่มีทางค้างครึ่งกลาง) ปิดช่องโหว่นี้
BEGIN TRANSACTION;
UPDATE players
SET gold = gold - 100
WHERE id = 42 AND gold >= 100; -- matches zero rows if gold is too low
INSERT INTO inventory (player_id, item_id, quantity)
VALUES (42, 'sword_01', 1)
ON CONFLICT (player_id, item_id)
DO UPDATE SET quantity = inventory.quantity + 1;
COMMIT;
ทุกอย่างระหว่าง BEGIN TRANSACTION กับ COMMIT ถือเป็นหน่วยเดียวที่แบ่งแยกไม่ได้ ถ้ามีอะไรล้มเหลวก่อนจะถึง COMMIT — crash, connection หลุด, constraint ผิดพลาด — database จะทำ rollback โดยอัตโนมัติ (ยกเลิกการเขียนทุกอย่างที่ทำภายใน transaction เหมือนกับว่าไม่เคยเกิดขึ้นเลย)
การหาแมตช์เป็นงาน backend อีกอย่างหนึ่งที่แยกจากทั้ง account service ข้างบนและ realtime game server จากบทก่อน ๆ player กด "Find Match"; client ส่ง skill rating ของตัวเอง (มักเรียกว่า MMR — ตัวเลขที่เกมคอยดูแลไว้เพื่อประเมินว่า player คนนั้นเก่งแค่ไหน ใช้เพื่อให้แมตช์ยุติธรรม) ไปที่ matchmaking service ซึ่งเก็บ player ที่รออยู่ทั้งหมดไว้ในคิว แล้วจับกลุ่มให้เป็นแมตช์ที่สมดุลกัน
บางเกมมี lobby service เพิ่มด้วย: พื้นที่เตรียมความพร้อมก่อนแมตช์ที่ party สามารถชวนเพื่อน, เลือก loadout, และกด ready ก่อนแมตช์จะเริ่มด้วยซ้ำ มันเป็น backend service เล็ก ๆ ของตัวเองเช่นกัน ไม่ใช่ game server เพราะไม่มีอะไรใน lobby ที่ต้องการ tick 60 ครั้งต่อวินาที — แค่อัปเดตเป็นครั้งคราว เหมือน backend endpoint อื่น ๆ ทั่วไป
เกมที่มีผู้เล่นพร้อมกันหลักล้านคนไม่สามารถรัน service เดี่ยว ๆ ตัวไหนได้เลย ไม่ว่าจะเป็น Auth หรือ Shop เป็น process เดียว — ไม่มีเครื่องไหนเครื่องเดียวตอบ request ได้เยอะขนาดนั้นทัน ทางแก้คือรันสำเนาเหมือนกันหลายชุด เรียกว่า instance ของแต่ละ service โดยมี load balancer (service ที่รับ request ที่เข้ามาทั้งหมด แล้วส่งต่อไปยัง instance ที่พร้อมใช้งานตัวใดตัวหนึ่ง) วางอยู่ด้านหน้า
สิ่งนี้ทำงานได้ก็ต่อเมื่อทุก instance เป็น stateless service (instance ที่ไม่จำอะไรเกี่ยวกับ player คนใดคนหนึ่งไว้ระหว่าง request — ทุกอย่างที่มันต้องใช้มาพร้อมกับ request เอง เช่น auth token หรือไม่ก็ไปดึงสด ๆ จาก database หรือ cache ที่แชร์กัน) เพราะไม่มี instance ไหนจำ player คนใดคนหนึ่งไว้เป็นการส่วนตัว load balancer จึงส่ง request ไปยัง instance ไหนก็ได้อย่างอิสระ และการเพิ่ม capacity ก็แค่เปิด instance เหมือนกันเพิ่มขึ้นมา — เรียกว่า horizontal scaling
มีเครื่องมืออีกสองอย่างที่จัดการเรื่อง scale นอกเหนือจากการเพิ่ม stateless instance Sharding คือการแบ่ง database ก้อนใหญ่ก้อนเดียวออกเป็นหลายก้อนเล็ก ๆ แต่ละก้อนถือข้อมูลแค่บางส่วน — player 1 ถึง 1,000,000 อยู่บน shard A, player 1,000,001 ถึง 2,000,000 อยู่บน shard B แบบนี้ไปเรื่อย ๆ เครื่อง database เครื่องเดียวมีเพดานว่ารับข้อมูลได้เท่าไหร่และรับ query ต่อวินาทีได้กี่ครั้ง sharding กระจายทั้ง storage และ load ไปหลายเครื่อง จุดแลกเปลี่ยนคือ query ที่ต้องการข้อมูลจาก player สองคนบนคนละ shard จะยากขึ้นมาก — เกมส่วนใหญ่เลือก shard key มักเป็น player_id หรือ region ที่ทำให้ query ประจำวันส่วนใหญ่อยู่ใน shard เดียวกัน
Caching แก้ปัญหาคนละแบบ: การอ่านจำนวนมากขอข้อมูล "hot" อันเดียวกันซ้ำ ๆ — top 100 ของ leaderboard, การเช็ค auth token เกือบทุก request Redis (in-memory key-value database ที่เร็วมาก มักวางไว้หน้า database หลักที่ช้ากว่าเพื่อทำหน้าที่เป็น cache) ตอบการอ่านซ้ำ ๆ พวกนี้โดยไม่ต้องแตะ database ที่ช้ากว่าเลย
// Pseudocode -- the "cache-aside" pattern
public Player GetPlayer(int playerId)
{
Player cached = redis.Get<Player>("player:" + playerId);
if (cached != null)
return cached; // cache hit -- no database query
Player fromDb = db.QueryOne(
"SELECT * FROM players WHERE id = @id", playerId);
redis.Set("player:" + playerId, fromDb, expiresIn: TimeSpan.FromMinutes(5));
return fromDb; // cache miss -- filled in for next time
}
backend จริง ๆ ไม่ใช่โปรแกรมเดียว — มันคือ service เล็ก ๆ หลายตัว (Auth, Shop, Inventory, Matchmaking) แนวทางนี้เรียกว่า microservices (แบ่ง backend ออกเป็น service เล็ก ๆ ที่ deploy แยกกันได้อิสระ แต่ละตัวรับผิดชอบส่วนของตัวเอง แทนที่จะเป็นโปรแกรมยักษ์ตัวเดียวที่ทำทุกอย่าง) การแบ่งแบบนี้ทำให้ทีม Shop deploy fix ได้โดยไม่ต้องแตะ Auth เลย แต่ก็หมายความว่า client ต้องรู้ address ของ service ทุกตัวด้วย ถ้าไม่มี gateway (จุดเข้าเดียวที่รู้จักกันดี ที่ทุก request ของ client ไปหาก่อนเสมอ ก่อนจะถูกส่งต่อไป) ก็ตัดความจำเป็นนั้นออกไปได้
gateway ยังรับหน้าที่งานที่ใช้ร่วมกันทุก service เพื่อไม่ให้แต่ละ service ต้องคิดใหม่เอง: เช็คว่ามี token และมันถูกต้องก่อนส่งต่อไปไหน, ถอดรหัส TLS (encryption ที่รักษาความเป็นส่วนตัวของ request ระหว่างทาง), และ rate limiting (ปฏิเสธ client ที่ส่ง request ถี่กว่าที่ player ปกติจะทำ ซึ่งเป็นด่านแรกที่บทเรื่อง anti-cheat จะพูดถึงแบบละเอียดกว่านี้) หลังจากผ่านทุกอย่างนั้นแล้วเท่านั้น request ถึงจะไปถึง Shop Service, Inventory Service หรือ service ไหนก็ตาม ซึ่งจะไปอ่านหรือเขียน database ที่แชร์กัน อาจจะผ่าน cache ก็ได้
เครือข่ายมือถือหลุด packet อยู่ตลอดเวลา ลองนึกภาพลำดับเหตุการณ์นี้: client ส่ง POST /shop/buy; Shop Service รัน transaction จาก section 9 สำเร็จ หัก gold และเพิ่ม item เรียบร้อย; มันส่ง 200 OK กลับไป — แต่ response นั้นไม่เคยไปถึง client เลย หายไปที่ไหนสักแห่งระหว่างทางกลับ client ที่ไม่เห็น response เลยคิดว่า request ล้มเหลว แล้วก็ retry POST /shop/buy เดิมเป๊ะ ๆ โดยอัตโนมัติ ถ้า handler แค่รันใหม่ตั้งแต่ต้น player จะถูกเก็บเงินค่าดาบสองครั้ง สำหรับการซื้อครั้งเดียวที่เขาทำจริง ๆ แค่ครั้งเดียว
ทางแก้คือ idempotency (request จะเป็น idempotent เมื่อการส่ง request เดิมเป๊ะ ๆ ซ้ำหลายครั้งให้ผลเหมือนกับส่งครั้งเดียว) สร้างโดยใช้ idempotency key (id ที่ไม่ซ้ำกันซึ่ง client สร้างขึ้นครั้งเดียวต่อความพยายามจริงหนึ่งครั้ง ส่งมาพร้อม request; server จำไว้ว่า key ไหนถูกจัดการไปแล้ว แล้วถ้าเจอ key ซ้ำ ก็ส่งผลลัพธ์เดิมกลับไปแทนที่จะทำงานซ้ำ)
CREATE TABLE processed_requests (
idempotency_key TEXT PRIMARY KEY,
player_id INTEGER NOT NULL,
result_json TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
public BuyResult HandleBuy(int playerId, string itemId, string idempotencyKey)
{
// Have we already handled this exact attempt before?
ProcessedRequest existing = db.QueryOne(
"SELECT result_json FROM processed_requests WHERE idempotency_key = @k",
idempotencyKey);
if (existing != null)
return BuyResult.FromJson(existing.result_json); // same answer, no re-charge
BuyResult result = DoTheActualPurchase(playerId, itemId); // Section 9's transaction
db.Execute(
"INSERT INTO processed_requests (idempotency_key, player_id, result_json) VALUES (@k, @p, @r)",
idempotencyKey, playerId, result.ToJson());
return result;
}
trace ที่คาดว่าจะได้:
Attempt 1 (key = "req-9f2a"): runs the purchase, gold 450 --> 350,
response sent but lost in transit
Attempt 2 (key = "req-9f2a", automatic retry): finds "req-9f2a" already
processed, returns the SAME stored result,
gold stays at 350
client สร้าง key ครั้งเดียวต่อการกดจริงหนึ่งครั้ง — GUID ก็เพียงพอแล้ว — แล้วใช้ key เดิมซ้ำสำหรับทุก retry อัตโนมัติของการกดครั้งนั้น การกดครั้งใหม่ในครั้งถัดไปจะสร้าง key ใหม่เอี่ยม ดังนั้นมันจะไม่มีวันถูกบล็อก
ClaimDailyReward ใหม่ให้ server เป็นคนตัดสินใจเรื่องจำนวนรางวัลและเช็คสิทธิ์เอง โดยที่ request ของ client ไม่ต้องมีตัวเลขรางวัลอะไรเลย
// BAD -- the server just believes whatever the client says the reward is
public void ClaimDailyReward(int playerId, int rewardGold)
{
Player player = db.GetPlayer(playerId);
player.Gold += rewardGold;
db.Save(player);
}
// Client call:
// POST /reward/claim { "playerId": 42, "rewardGold": 5000 }
// GOOD -- client sends only an intent; server decides the amount
// and checks whether today's reward was already claimed.
public ClaimResult ClaimDailyReward(int playerId)
{
Player player = db.GetPlayer(playerId);
if (player.LastRewardClaimDate == Today())
return ClaimResult.Fail("already claimed today");
int reward = RewardTable.GetDailyReward(player.LoginStreakDays); // server decides
player.Gold += reward;
player.LastRewardClaimDate = Today();
db.Save(player);
return ClaimResult.Ok(reward);
}
// Client call now carries no numbers at all:
// POST /reward/claim { "playerId": 42 }
client ไม่ส่ง rewardGold เลยอีกต่อไป — มันแค่ขอเก็บรางวัลเท่านั้น server ไปค้นจำนวนรางวัลจริงจาก RewardTable ของตัวเอง และเช็ค LastRewardClaimDate เอง ดังนั้นผลลัพธ์ไม่ได้ขึ้นอยู่กับอะไรที่ client ส่งมาเลย กฎเดียวกับ section 5: request อธิบายความตั้งใจ ส่วน server คำนวณผลลัพธ์
'hat_003' (ราคา 250 gems): ต้องหักราคา, เพิ่ม row การครอบครอง, และทำให้แน่ใจว่า crash ระหว่างกลางจะไม่ทำให้ player ถูกเก็บเงินไปแล้วแต่ไม่ได้อะไรเลย
CREATE TABLE players (
id INTEGER PRIMARY KEY,
gems INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE cosmetics (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
price INTEGER NOT NULL
);
CREATE TABLE owned_cosmetics (
player_id INTEGER NOT NULL REFERENCES players(id),
cosmetic_id TEXT NOT NULL REFERENCES cosmetics(id),
PRIMARY KEY (player_id, cosmetic_id)
);
BEGIN TRANSACTION;
UPDATE players
SET gems = gems - 250
WHERE id = 7 AND gems >= 250;
-- if the UPDATE above matched zero rows, player 7 did not have
-- enough gems; the application checks the affected-row count here
-- and calls ROLLBACK instead of continuing:
-- if (rowsAffected == 0) { ROLLBACK; return Fail("not enough gems"); }
INSERT INTO owned_cosmetics (player_id, cosmetic_id)
VALUES (7, 'hat_003');
COMMIT;
เงื่อนไข gems >= 250 ใน WHERE clause ทำให้ UPDATE ไปแตะ row จำนวนศูนย์แถวอย่างเงียบ ๆ แทนที่จะปล่อยให้ค่าติดลบเมื่อ player มี gems ไม่พอ การห่อทั้งสอง statement ไว้ระหว่าง BEGIN TRANSACTION กับ COMMIT หมายความว่าถ้า process crash หรือ connection หลุดระหว่าง UPDATE กับ INSERT database จะ rollback การหัก gems ด้วย — player จะไม่มีวันถูกเก็บเงินไปแล้วแต่ไม่ได้หมวกอะไรเลย ตรงกับความล้มเหลวที่ trace ไว้ใน section 9
// BAD -- retrying this request grants the reward a second time
public ClaimResult ClaimStreakReward(int playerId)
{
Player player = db.GetPlayer(playerId);
player.Gold += 100;
db.Save(player);
return ClaimResult.Ok(100);
}
// GOOD -- a repeated request with the same key returns the
// original result instead of granting the reward again.
public ClaimResult ClaimStreakReward(int playerId, string idempotencyKey)
{
ProcessedRequest existing = db.QueryOne(
"SELECT result_json FROM processed_requests WHERE idempotency_key = @k",
idempotencyKey);
if (existing != null)
return ClaimResult.FromJson(existing.result_json); // same answer, no re-grant
Player player = db.GetPlayer(playerId);
player.Gold += 100;
db.Save(player);
ClaimResult result = ClaimResult.Ok(100);
db.Execute(
"INSERT INTO processed_requests (idempotency_key, player_id, result_json) VALUES (@k, @p, @r)",
idempotencyKey, playerId, result.ToJson());
return result;
}
// Client generates ONE key per genuine tap and reuses it on every retry:
// POST /reward/claim-streak { "playerId": 42, "idempotencyKey": "req-7f3c" }
การเรียกครั้งแรกด้วย key "req-7f3c" รันตามปกติและเก็บผลลัพธ์ไว้ภายใต้ key นั้น ถ้า response หายไปและ client retry ด้วย key เดิมเป๊ะ ๆ การเรียกครั้งที่สองจะเจอ row ที่เก็บไว้แล้วส่งกลับไปตรง ๆ — player.Gold ถูกแตะแค่ครั้งเดียวสำหรับ key นั้น ส่วนการ claim ใหม่จริง ๆ เช่น streak reward ของวันพรุ่งนี้ จะใช้ key ใหม่เอี่ยมที่สร้างขึ้นสด ๆ ดังนั้นมันจะไม่มีวันถูกบล็อกโดย key เก่า