ข้ามไปยังเนื้อหา

Cache Reads

api/src/cache.rs: function เล็ก ๆ generic สามตัว — get_json, set_json, invalidate — ที่อ่านและเขียนค่าแบบ Serialize/DeserializeOwned ใด ๆ เป็น JSON string ใน Redis ผ่าน deadpool_redis::Pool ตัวเดียวกับที่ AppState มีอยู่แล้ว จากนั้นต่อ helper ชุดนี้เข้ากับ boards::service::get_tree — function เบื้องหลัง GET /boards/:id — เพื่อให้ request ที่ขอ tree ของ board เดิมซ้ำ ได้ค่าจาก Redis แทนที่จะรันขั้นตอนประกอบ tree ที่มี query หกครั้งขึ้นไปจาก boards ใหม่ทุกครั้ง

struct สามตัวใน boards::modelBoard, Column, Card, ColumnWithCards, BoardTree — ได้ derive ตัวที่สองเพิ่มมา คือ Deserialize คู่กับ Serialize ที่มีอยู่แล้ว ก่อนหน้านี้ struct พวกนี้ทำหน้าที่ serialize ออก ไปเป็น JSON response body อย่างเดียว แต่การอ่าน BoardTree ที่ cache ไว้กลับออกมาจาก Redis แปลว่าต้อง deserialize JSON ก้อนเดียวกันนั้น เข้า มาด้วย ดังนั้นทุก type ในเส้นทางนี้ต้องรองรับทั้งสองทิศทาง

GET /boards/:id คือ endpoint ที่โดนเรียกบ่อยที่สุดใน TaskFlow — ทุกครั้งที่เปิดดู board, ทุก page load, ทุกครั้งที่ reconnect หลัง connection หลุด ก็ดึง tree เดิมซ้ำ และหัวข้อข้อดีข้อเสียของ boards ก็เคยชี้ไว้แล้วว่าการประกอบ get_tree เป็น N+1 query: หนึ่ง query สำหรับ column ของ board, อีกหนึ่ง query ต่อ column สำหรับ card ในนั้น

พอเปิด board เดิมค้างไว้สาม browser tab ก็เท่ากับรัน query ชุดนี้ซ้ำสามครั้งต่อนาที ทั้งที่ข้อมูลจริงเปลี่ยนอย่างมากแค่ครั้งเดียว การ cache BoardTree ที่ประกอบเสร็จแล้ว — ไม่ใช่แถว Board/Column/Card แยกชิ้น แต่เป็น response body ที่เสร็จสมบูรณ์ — ทำให้ read ซ้ำพวกนั้นเหลือแค่ Redis GET ครั้งเดียว ข้าม round trip ไป Postgres (และต้นทุน N+1 query) ไปเลยจนกว่าจะมีอะไรเปลี่ยนจริง ๆ

function สามตัวใน cache.rs ตั้งใจทำให้ generic เหนือ T: Serialize/T: DeserializeOwned แทนที่จะ hardcode เป็น BoardTree เผื่อบทหลัง ๆ อยาก cache resource อื่นด้วยวิธีเดียวกัน — get_json::<BoardTree>(...) อ่านแล้วเหมือนกับ get_json::<SomeOtherType>(...) ทุกประการ ตัว pattern cache-aside เอง — เช็ค cache ก่อน ถ้าไม่เจอค่อยไปรัน query จริง แล้วเติม cache ก่อน return — ก็เป็นรูปแบบมาตรฐานสำหรับ read ใด ๆ ที่ module นี้ (หรือ module ในอนาคต) อยากทำให้เร็วขึ้น: cache.rs คือกลไกที่ใช้ซ้ำได้ ส่วน get_tree คือผู้เรียกใช้รายแรก

Cache tree ที่ประกอบแล้วของ GET /boards/:id (ตัวที่เราใช้) เทียบกับปล่อยให้ทุก read ยิงเข้า Postgres โดยตรง

  • ข้อดี: cache hit ข้าม N+1 query set ทั้งชุด — Redis round trip ครั้งเดียวแทนที่จะเป็น 1 + (จำนวน column) round trip ไป Postgres — และได้ผลมากที่สุดกับ pattern การใช้งานจริงของ TaskFlow ที่เจอบ่อย: member คนเดิมหรือคนอื่น refresh board เดิมซ้ำ ๆ ตอนที่ไม่มีใคร edit อยู่เลย นอกจากนี้ Redis GET แบบ in-memory ก็เร็วกว่าต่อครั้งอยู่แล้ว แม้จะเทียบกับ Postgres query ที่มี index ตัวเดียว ดังนั้นแม้แต่ board ที่มี column เดียว card เดียวก็ยังได้ประโยชน์ เพียงแต่ไม่มากเท่า board ที่มียี่สิบ column
  • ข้อเสีย: caching เป็นเครื่องมือที่ผิดสำหรับข้อมูลที่เปลี่ยนทุกครั้งที่อ่าน หรืออ่านแค่ครั้งเดียว — write ที่เติม cache (set_json) เป็น overhead ล้วน ๆ ถ้าไม่มีใครมาอ่านซ้ำอีกก่อน TTL หมดอายุ และ board ที่มีคน edit พร้อมกันหนัก ๆ (ดู invalidation) ก็ต้องจ่ายค่า cache write ที่แทบไม่ได้ใช้ ก่อนที่ invalidation ครั้งถัดไปจะมาล้างทิ้ง อีกอย่างคือ caching เพิ่ม data store ตัวที่สองที่อาจไม่ตรงกับ source of truth ชั่วขณะหนึ่ง — Postgres ถูกต้องเสมอทันทีที่ write commit สำเร็จ ส่วนสำเนาใน cache ถูกต้องแค่จนกว่าจะโดน invalidate หรือ TTL หมดอายุ แล้วแต่อย่างไหนมาก่อน

TTL 60 วินาที (ตัวที่เราใช้) เทียบกับ TTL ที่ยาวกว่ามาก หรือไม่มี TTL เลย

  • ข้อดี: 60 วินาทีนานพอจะรองรับ pattern “board เดิม อ่านรัว ๆ หลายครั้ง” ที่ cache นี้มีไว้โดยเฉพาะ — user กด refresh, page re-fetch ตอน focus, เพื่อนร่วมทีมสองสามคนแวะดู board เดียวกันภายในนาทีเดียวกัน — แต่ก็ยังสั้นพอที่การ invalidate ที่พลาดไป (bug หรือ code path ที่ลืมเรียก cache::invalidate) จะ self-heal ได้ภายในหนึ่งนาที แทนที่จะ serve ข้อมูล stale ไปเรื่อย ๆ ไม่มีวันจบ ดังนั้น TTL จึงเป็น safety net ที่รองอยู่ใต้การเรียก invalidate แบบ explicit ที่ invalidation เพิ่มเข้าไปในทุก write ไม่ใช่ตัวแทนของการเรียกนั้น
  • ข้อเสีย: 60 วินาทีก็ยังเป็นช่วงเวลาจริงที่ cache ซึ่ง invalidate ไปถูกต้องแล้ว อาจโดน เติมใหม่ ด้วยข้อมูลที่ตามหลัง write อยู่หนึ่งก้าว ถ้า request สองตัวแข่งกับ mutation พอดิบพอดี (อธิบายละเอียดในช่วง stale-read ของ invalidation) ถ้าตั้ง TTL ยาวกว่านี้มากก็จะลด overhead การเติม cache ลงไปอีก แต่ก็ขยายช่วงเวลาเสี่ยงนั้นให้กว้างขึ้น และต้องพึ่ง invalidation ให้ไม่พลาดเลยมากขึ้นด้วย ส่วน TaskFlow เลือก 60 วินาที เพราะเป็นจุดที่ “safety net ราคาถูก” กับ “ความ stale ที่สังเกตเห็นได้” ยังจริงทั้งคู่

สร้าง taskflow/backend/api/src/cache.rs:

use deadpool_redis::redis::AsyncCommands;
use serde::{de::DeserializeOwned, Serialize};
use crate::error::{AppError, AppResult};
pub async fn get_json<T: DeserializeOwned>(
pool: &deadpool_redis::Pool,
key: &str,
) -> AppResult<Option<T>> {
let mut conn = pool
.get()
.await
.map_err(|err| AppError::Internal(err.into()))?;
let raw: Option<String> = conn
.get(key)
.await
.map_err(|err| AppError::Internal(err.into()))?;
match raw {
Some(json) => {
let value =
serde_json::from_str(&json).map_err(|err| AppError::Internal(err.into()))?;
Ok(Some(value))
}
None => Ok(None),
}
}
pub async fn set_json<T: Serialize>(
pool: &deadpool_redis::Pool,
key: &str,
value: &T,
ttl_secs: u64,
) -> AppResult<()> {
let mut conn = pool
.get()
.await
.map_err(|err| AppError::Internal(err.into()))?;
let json = serde_json::to_string(value).map_err(|err| AppError::Internal(err.into()))?;
conn.set_ex::<_, _, ()>(key, json, ttl_secs)
.await
.map_err(|err| AppError::Internal(err.into()))?;
Ok(())
}
pub async fn invalidate(pool: &deadpool_redis::Pool, key: &str) -> AppResult<()> {
let mut conn = pool
.get()
.await
.map_err(|err| AppError::Internal(err.into()))?;
conn.del::<_, ()>(key)
.await
.map_err(|err| AppError::Internal(err.into()))?;
Ok(())
}

.map_err(|err| AppError::Internal(err.into())) เป็น pattern เดียวกับที่ auth::middleware::AuthUser ใช้กับการ lookup Redis ของตัวเองอยู่แล้ว — deadpool_redis::PoolError หรือ redis::RedisError แปลงเป็น anyhow::Error ผ่าน .into() แล้วแปลงเป็น AppError::Internal ผ่าน conversion #[from] ที่ error.rs นิยามไว้อยู่แล้ว ส่วน turbofish ของ set_ex และ del (::<_, _, ()>, ::<_, ()>) มีไว้ตรึง generic return type ให้เป็น () — ทั้งสอง command คืน Redis status reply ที่เราไม่ได้ใช้ และถ้าไม่ระบุ type ให้ชัด Rust ก็ไม่มีอะไรให้ infer

ข้อตกลง cache key: cache:board:{board_id} — Redis STRING ที่เก็บ BoardTree แบบ serialize เป็น JSON แล้ว key นี้ตั้งใจใส่ namespace cache: ไว้ข้างหน้า และขอย้ำให้ชัดว่าไม่ใช่ตัวเดียวกับ board:{board_id} — Redis pub/sub CHANNEL ที่ Realtime จะสร้างในอีกสองบทข้างหน้า เพื่อ broadcast การอัปเดต board แบบสดผ่าน WebSocket

ในทางเทคนิค Redis STRING key กับชื่อ pub/sub channel อยู่กันคนละ namespace โดยสิ้นเชิง — Redis ไม่มีทางสับสน GET cache:board:{id} กับ PUBLISH board:{id} ... ต่อให้ชื่อชนกันพอดี — แต่คนที่กวาดตาอ่านโค้ดผ่าน ๆ สับสนสองอย่างนี้ได้ง่ายมาก ดังนั้น module นี้จะใส่ prefix cache: เสมอ ไม่ย่อทิ้งเด็ดขาด

อัปเดต derive ใน taskflow/backend/api/src/boards/model.rs:

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Board {
pub id: Uuid,
pub owner_id: Uuid,
pub title: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Column {
pub id: Uuid,
pub board_id: Uuid,
pub title: String,
pub position: f64,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Card {
pub id: Uuid,
pub column_id: Uuid,
pub title: String,
pub description: Option<String>,
pub position: f64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ColumnWithCards {
#[serde(flatten)]
pub column: Column,
pub cards: Vec<Card>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BoardTree {
#[serde(flatten)]
pub board: Board,
pub columns: Vec<ColumnWithCards>,
}

#[serde(flatten)] ทำงานเหมือนกันทั้งสองทิศทาง — การ deserialize BoardTree กลับออกมาจาก JSON string ที่ cache ไว้ จะประกอบ field board: Board ที่ซ้อนอยู่ขึ้นมาใหม่จาก key ระดับบนสุดอย่าง id/title/ฯลฯ ที่ flatten ออกไปตอนขาออก ส่วน FromRow ไม่ได้รับผลกระทบ เพราะควบคุมแค่วิธีที่ sqlx อ่านแถวจาก Postgres ซึ่งคนละเรื่องกับ JSON round trip ผ่าน Redis ของ serde โดยสิ้นเชิง

อัปเดต get_tree ใน taskflow/backend/api/src/boards/service.rs — ต้อง import crate::{cache, state::AppState} เพิ่มที่ด้านบนไฟล์ด้วย:

pub async fn get_tree(state: &AppState, user_id: Uuid, board_id: Uuid) -> AppResult<BoardTree> {
let db = &state.db;
assert_member(db, user_id, board_id).await?;
let cache_key = format!("cache:board:{board_id}");
if let Some(tree) = cache::get_json::<BoardTree>(&state.redis, &cache_key).await? {
return Ok(tree);
}
let board = repo::find_board(db, board_id)
.await?
.ok_or(AppError::NotFound)?;
let columns = repo::find_columns(db, board_id).await?;
let mut columns_with_cards = Vec::with_capacity(columns.len());
for column in columns {
let cards = repo::find_cards_for_column(db, column.id).await?;
columns_with_cards.push(ColumnWithCards { column, cards });
}
let tree = BoardTree {
board,
columns: columns_with_cards,
};
cache::set_json(&state.redis, &cache_key, &tree, 60).await?;
Ok(tree)
}

ตอนนี้ get_tree รับ state: &AppState แทน db: &PgPool — signature เปลี่ยนแบบเดียวกับที่ move-reorder เคยทำกับ move_card ด้วยเหตุผลเดียวกันเป๊ะ: function นี้ต้องการ resource ตัวที่สองจาก AppState (ที่นั่นคือ WebSocket hub ในอนาคต ที่นี่คือ state.redis) ซึ่ง &PgPool เปล่า ๆ ให้ไม่ได้

ฝั่ง assert_member ยังรับ db: &PgPool เหมือนเดิม และรันก่อนอ่าน cache ไม่ใช่หลัง เพราะ cache hit ต้องไม่มีทางข้าม authorization ไปได้ ระบบจึงเช็ค membership กับ Postgres ทุกครั้งที่เรียก ไม่ว่าจะ hit หรือ miss แล้วต่อเมื่อผ่านตรงนั้น get_tree ถึงจะแตะ Redis

อัปเดต get_board:

pub async fn get_board(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(board_id): Path<Uuid>,
) -> AppResult<Json<BoardTree>> {
let tree = service::get_tree(&state, user_id, board_id).await?;
Ok(Json(tree))
}

ส่วนที่เปลี่ยนไปจากเวอร์ชันใน boards มีแค่ &state แทน &state.dbget_board ดึง AppState ทั้งก้อนออกมาผ่าน State(state) อยู่แล้ว จึงไม่ต้องเพิ่ม extractor ใหม่ แค่ส่ง slice อื่นของ value เดิมลงไปแทน

mod boards;
mod cache;
mod cards;
mod columns;
mod config;
mod db;
mod error;
mod labels;
mod state;
Terminal window
cargo check -p api

เปิด stack ขึ้นมา ขอ token แล้วสร้าง board พร้อม column กับ card (ใช้ pattern เดิมจาก boards, columns, และ cards):

Terminal window
cd taskflow/infra && docker compose up -d db redis
cd ../backend && cargo run -p api &
TOKEN=$(curl -s -X POST http://localhost:8080/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","password":"correct horse battery staple","display_name":"Ada"}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
BOARD_ID=$(curl -s -X POST http://localhost:8080/boards \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Sprint 12"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')

ยืนยันว่า cache key ยังไม่มี:

Terminal window
docker compose exec redis redis-cli GET cache:board:$BOARD_ID
(nil)

ดึง board — cache miss และเติม cache เป็นผลข้างเคียง:

Terminal window
curl -s http://localhost:8080/boards/$BOARD_ID -H "Authorization: Bearer $TOKEN"

ยืนยันว่า key มีอยู่แล้ว พร้อม TTL ต่ำกว่า 60 วินาที:

Terminal window
docker compose exec redis redis-cli GET cache:board:$BOARD_ID
docker compose exec redis redis-cli TTL cache:board:$BOARD_ID
{"id":"...","owner_id":"...","title":"Sprint 12","created_at":"...","columns":[]}
(integer) 58

ดึงอีกครั้ง — response body เดิม แต่คราวนี้เป็น cache hit ที่ serve ออกมาโดยไม่แตะ Postgres เลย:

Terminal window
curl -s http://localhost:8080/boards/$BOARD_ID -H "Authorization: Bearer $TOKEN"

รอให้เกิน TTL (หรือจะ docker compose exec redis redis-cli DEL cache:board:$BOARD_ID เพื่อบังคับก็ได้) แล้วยืนยันว่า key หายไป และ GET ครั้งถัดไปจะเติม key กลับมาใหม่:

Terminal window
sleep 61
docker compose exec redis redis-cli GET cache:board:$BOARD_ID
(nil)

คุณสร้าง helper generic สามตัวใน cache.rsget_json, set_json, invalidate — เหนือ deadpool_redis::Pool จากนั้นเพิ่ม Deserialize คู่กับ Serialize ในทุก struct ของ boards::model เพื่อให้ BoardTree ที่ cache ไว้ round-trip ผ่าน Redis เป็น JSON ได้ แล้วต่อ cache-aside pattern เข้ากับ get_tree: เช็ค cache:board:{board_id} หลัง authorization ผ่าน ถ้า hit ก็ return เลย ถ้าไม่ก็ประกอบ tree แบบเดิมที่ boards ทำมาตลอด แล้วเติม cache ด้วย TTL 60 วินาทีก่อน return

นอกจากนี้คุณยังเห็นแล้วว่าทำไม cache:board:{id} (STRING) กับ board:{id} (pub/sub CHANNEL ที่จะมาใน Realtime) ถึงต้องแยก namespace กันให้ชัด ทั้งที่หน้าตาแทบจะเหมือนกัน บทถัดไป invalidation จะทำให้แน่ใจว่า cache นี้จะไม่ serve ข้อมูล board ที่ stale เกิน 60 วินาทีหลัง write จริง ด้วยการลบ key ทิ้งจากทุก mutation ที่แตะ tree ของ board ไม่ใช่ตามไปอัปเดต key