Tech Blog

แตะ "DB ที่เปิดไฟล์โดยตรง" จากหลายโพรเซสพร้อมกัน จนพังไปสองครั้ง — จนกระทั่งลดผู้เขียนให้เหลือหนึ่งเดียวและแก้ไขถาวร

RAG ChromaDB 並行処理 データ整合性 運用

บทนำ

ผมใช้งานฐานความรู้ส่วนตัว (RAG) ที่เก็บบันทึกประจำวันและบทสนทนาในอดีต แล้วดึงกลับมาได้ด้วยความหมาย เนื้อหาข้างในเป็น เวกเตอร์ DB — ฐานข้อมูลที่แปลงข้อความให้เป็นชุดตัวเลข (เวกเตอร์) แล้วเก็บไว้เพื่อค้นหาด้วยความใกล้เคียงทางความหมาย ในนี้มีการตัดสินใจและบทเรียนในอดีตสะสมอยู่ สำหรับผมมันใกล้เคียงกับความทรงจำเลยทีเดียว

เวกเตอร์ DB นั้นพัง ทั้งดัชนีไปสองครั้ง ทั้งการค้นหาและการนับจำนวนก็ทำไม่ได้ ความทรงจำกลายเป็นอ่านไม่ได้ สาเหตุไม่ใช่บั๊กที่หวือหวา แต่เป็น ความผิดพลาดเชิงโครงสร้างในวิธีใช้งาน บทความนี้เป็นบันทึกการค้นหาสาเหตุที่มันพัง จนถึงการสร้างใหม่ให้เป็นรูปแบบที่ไม่มีวันพังอีก

หลังจากทำพังไปแล้ว ผมยังทำผิดพลาดอีกอย่างหนึ่งคือ “ตัดสินใจว่าจะสำรองข้อมูลแต่ไม่ได้ทำจริง” เรื่องราวฝั่งการป้องกันนั้นเขียนไว้ใน เรื่องราวการตัดสินใจว่า “จะสำรองข้อมูล” แต่ไม่ได้ทำจริง แล้วทำลายเวกเตอร์ DB สำหรับความจำสองครั้ง บทความนี้เป็นด้าน รากเหง้า ของเรื่องนั้น คือทำไมมันถึงพังตั้งแต่แรก


อะไรพัง

อาการชัดเจนมาก การดำเนินการพื้นฐานกับเวกเตอร์ DB อย่างการนับจำนวนและการค้นหา จู่ๆ ก็ล่มด้วย Access Violation (การเข้าถึงหน่วยความจำที่ผิดกฎ) ไฟล์ที่เก็บข้อมูลยังอยู่ครบ แต่ ดัชนี HNSW ที่ใช้ดึงข้อมูลออกมา — ดัชนีที่ทำให้การค้นหาเพื่อนบ้านใกล้เคียงของเวกเตอร์เร็วขึ้น ถ้าตรงนี้พังการค้นหาก็ใช้งานไม่ได้เลย — เสียหาย

ครั้งแรกผมกู้คืนได้ แต่อีกไม่กี่วันต่อมามันก็พังแบบเดิมอีก ถ้าพังที่จุดเดิมสองครั้ง นั่นไม่ใช่เรื่องบังเอิญ แต่เป็นเรื่องโครงสร้าง ผมเลิกทำการแก้ไขตามอาการแล้วเปลี่ยนมาสืบหาสาเหตุที่แท้จริง


ทำไมถึงพัง: ทุกคนเปิดไฟล์เดียวกันพร้อมกัน

สิ่งที่ผมใช้อยู่คือโหมด embedded มาตรฐานของ DB (embedded DB = โหมดที่แอปเปิดไฟล์ของ DB โดยตรงเพื่ออ่านเขียนภายในโพรเซสของตัวเอง โดยไม่ต้องตั้งเซิร์ฟเวอร์แยกต่างหาก เป็นวิธีที่สะดวก) สำหรับการใช้งานคนเดียว วิธีนี้ก็เพียงพอแล้ว

ปัญหาคือการใช้งานจริงไม่ใช่ “คนเดียว” ฐานความรู้นี้มีหลายโพรเซสที่แตะไฟล์ DB เดียวกัน

        ไฟล์ DB เดียวกัน (chroma_db)
                 ▲  ▲  ▲  ▲
   ┌─────────────┘  │  │  └─────────────┐
   │        ┌───────┘  └───────┐        │
 MCPเซิร์ฟเวอร์  ไคลเอนต์อื่น    input hook   สคริปต์ใช้แล้วทิ้ง
 (ประจำ)       (ต่อบทสนทนา)    (ทุก prompt)   (batch/ซ่อมแซม ฯลฯ)
   │            │               │            │
   └──── ทุกคนเปิดไฟล์เดียวกันเขียนพร้อมกัน ────┘

              ดัชนี HNSW พัง (ละเมิดสมมติฐานโพรเซสเดียว)

embedded DB จะอัปเดตไฟล์ดัชนีโดย สมมติว่ามีโพรเซสเดียวเปิดใช้งานอยู่ เมื่อมีหลายโพรเซสเขียนเข้ามาพร้อมกัน การอัปเดตดัชนีจะขัดแย้งกันและพัง เครื่องมือที่สร้างมาสำหรับคนเดียว ถูกใช้งานโดยหลายคนโดยไม่รู้ตัว — นั่นคือตัวตนที่แท้จริงของการพังสองครั้ง


แก้ไขถาวรที่หนึ่ง: ลดผู้เขียนให้เหลือหนึ่งเดียว

แก่นของมาตรการนี้เรียบง่าย ทำให้มีโพรเซสที่เปิดไฟล์ DB ได้แค่หนึ่งเดียว

วิธีทำคือเปลี่ยน DB ให้เป็น โหมดเซิร์ฟเวอร์ (โหมดที่ตั้ง DB ขึ้นเป็นเซิร์ฟเวอร์โพรเซสอิสระหนึ่งตัว ส่วนที่เหลือทั้งหมดเชื่อมต่อไปยังเซิร์ฟเวอร์นั้นเพื่ออ่านเขียน) ด้วยวิธีนี้ มีแค่โพรเซสเซิร์ฟเวอร์ตัวเดียวที่เปิดไฟล์โดยตรง ส่วนโพรเซสอื่นๆ แค่ขอผ่าน HTTP เท่านั้น ผู้เขียนถูกลดให้เหลือหนึ่งเดียว ความขัดแย้งจึงหายไปเอง

【ก่อนเปลี่ยน】แต่ละโพรเซสเปิดไฟล์โดยตรง        → ผู้เขียนหลายคน → พัง
【หลังเปลี่ยน】แต่ละโพรเซส ──HTTP──▶ เซิร์ฟเวอร์ Chroma ──▶ ไฟล์
                                    (มีแค่ตรงนี้เท่านั้นที่เปิดไฟล์)

ในโค้ด ผมทำให้การเข้าถึง DB ต้องผ่านทางเข้าเดียวเสมอ (get_client()) และ ห้ามใช้วิธีเดิมที่เปิดไฟล์โดยตรง (PersistentClient) แทนที่จะปล่อยให้การห้ามนี้เป็นแค่ “ระวังไว้” ผมตรึงมันไว้ด้วยคอมเมนต์ในฟังก์ชันทางเข้า เพื่อปิดเส้นทางที่อาจเกิดซ้ำ

def get_client():
    """รับ Chroma client (ถ้ายังไม่ได้เริ่มเซิร์ฟเวอร์จะเริ่มอัตโนมัติแล้วเชื่อมต่อ)
    ห้ามใช้ PersistentClient โดยตรง (เส้นทางที่ทำให้เกิดการพังแบบ multi-process ซ้ำอีก)
    การเข้าถึง chroma_db ต้องผ่านฟังก์ชันนี้เท่านั้น"""
    # HttpClient = ผ่านเซิร์ฟเวอร์ ไม่เปิดไฟล์โดยตรง
    client = chromadb.HttpClient(host="127.0.0.1", port=8800)
    client.heartbeat()
    return client

มีจุดเล็กๆ แต่ได้ผลอยู่หนึ่งจุด การเริ่มเซิร์ฟเวอร์ซ้ำสองก็ถูกป้องกันด้วยหมายเลขพอร์ตเองโดยไม่ต้องทำอะไรเพิ่ม ถ้ามีโพรเซสหนึ่งพยายามเริ่มเซิร์ฟเวอร์ในขณะที่มีคนใช้พอร์ตเดียวกันอยู่แล้ว โพรเซสที่มาทีหลังจะไม่สามารถจองพอร์ตได้และปิดตัวลงทันที คุณสมบัติพื้นฐานที่ว่า “พอร์ตหนึ่งใช้ได้แค่คนเดียว” ทำหน้าที่เป็น exclusive lock ของ “มีเซิร์ฟเวอร์แค่ตัวเดียว” ได้เลยโดยไม่ต้องเพิ่มกลไกใหม่ ใช้ข้อจำกัดที่มีอยู่แล้วให้เป็นประโยชน์


แก้ไขถาวรที่สอง: แม้ไฟล์จะปกติ แต่เนื้อหาซ้ำกัน

การทำเป็นเซิร์ฟเวอร์ทำให้การพังทางกายภาพ “ไฟล์ดัชนีเสียหาย” หายไป แต่ยังมีปัญหาการทำงานพร้อมกันอีกชั้นหนึ่งที่เหลืออยู่ การซ้ำเชิงตรรกะ

ฐานความรู้นี้จะตรวจสอบก่อนเขียนว่า “มีเนื้อหาเดียวกันอยู่แล้วหรือไม่” แล้วปฏิเสธถ้ามี (การกำจัดข้อมูลซ้ำ) การตรวจสอบนี้มีสองขั้นตอนคือ “อ่าน (ตรวจสอบว่ามีหรือไม่) → เขียนถ้าไม่มี” ตรงนี้ ถ้ามีหลายโพรเซสเข้ามาเขียน เนื้อหาที่เกือบเหมือนกัน ในเวลาที่เกือบพร้อมกัน ทั้งคู่จะอ่านได้ว่า “ยังไม่มี” แล้วทั้งคู่ก็เขียน การตรวจสอบการซ้ำถูกทั้งสองฝ่ายหลบเลี่ยงไปพร้อมกัน

โพรเซส A: ตรวจสอบว่ามีหรือไม่ → ไม่มี ┐
โพรเซส B: ตรวจสอบว่ามีหรือไม่ → ไม่มี ┤ ← ทั้งคู่ตรวจสอบ "ก่อนเขียน" จึงได้ผลลัพธ์ "ไม่มี" ทั้งคู่
โพรเซส A: เขียน                     ┘
โพรเซส B: เขียน                     → เนื้อหาเดียวกันเข้าไปซ้ำสอง

โหมดเซิร์ฟเวอร์ทำให้ “ผู้เขียนไฟล์” เหลือหนึ่งเดียว แต่ยังไม่ได้รวม “การดำเนินการต่อเนื่องตั้งแต่ตรวจสอบจนถึงเขียน” เข้าด้วยกัน ผมจึง ส่งการดำเนินการเขียนทั้งหมดผ่านประตูเดียว (mutex) mutex คือด่านที่ให้ผ่านได้ทีละคนเท่านั้น ถ้าทำ “ตรวจสอบ → เขียน” ทั้งหมดในประตูนี้ B จะต้องรอจนกว่า A จะผ่านไปหมดแล้ว ทำให้ B อ่านได้ถูกต้องว่า “มีอยู่แล้ว”

# mutex สำหรับการเขียนระหว่างโพรเซส
# ทำให้ลำดับ "ตรวจสอบการซ้ำ (อ่าน) → เขียน" เป็นแบบเรียงลำดับ ป้องกันการลงทะเบียนซ้ำจากการเขียนพร้อมกัน
# reentrant (แม้จะถือแบบซ้อนกันก็ไม่ deadlock กับตัวเอง)
# เมื่อ timeout จะไม่กลืนไว้เฉยๆ แต่ทำให้เป็น exception ปรากฏออกมา (ไม่ปล่อยให้ล้มเหลวแบบเงียบๆ)
_WRITE_MUTEX = FileLock(str(DATA_DIR / ".write_mutex.lock"), timeout=180)

มีสิ่งหนึ่งที่ผมตั้งใจเลือก เมื่อประตูหมดเวลา ผมทำให้มัน ปรากฏเป็น exception แทนที่จะเพิกเฉยไปเงียบๆ การรอที่นานผิดปกติเป็นสัญญาณว่ามีอะไรติดขัดอยู่ การกลืนมันไว้จะเป็นเมล็ดพันธุ์ของความล้มเหลวครั้งต่อไป การป้องกันจะมีความหมายก็ต่อเมื่อรู้ตัวได้ว่าล้มเหลวเมื่อไหร่


รับประกัน “ไม่พัง” ด้วยโครงสร้าง ไม่ใช่ด้วยความระมัดระวัง

ย้อนกลับมามอง ทั้งการพังสองครั้งและการซ้ำ มีรากเดียวกันคือ ใช้เครื่องมือที่สร้างมาโดยสมมติว่ามีโพรเซสเดียว ในความเป็นจริงที่มีหลายโพรเซส และวิธีแก้ไขก็สอดคล้องกัน — รวมเส้นทางที่แตะพร้อมกันให้เป็นหนึ่งเดียว

  • การพังทางกายภาพหายไปด้วยการ ลดโพรเซสที่เปิดไฟล์ให้เหลือหนึ่งเดียว (ผ่านเซิร์ฟเวอร์)
  • การซ้ำเชิงตรรกะหายไปด้วยการ ส่งขั้นตอนตั้งแต่ตรวจสอบจนถึงเขียนผ่านประตูเดียว (mutex)

สิ่งที่สำคัญคือทั้งสองอย่างไม่ได้จบด้วย “ต่อไปจะระวัง” วิธีเดิมที่เปิดไฟล์โดยตรง ถูกทำให้ใช้ไม่ได้ ที่ฟังก์ชันทางเข้า การเริ่มเซิร์ฟเวอร์ซ้ำสอง ถูกป้องกันด้วยข้อจำกัดที่มีอยู่แล้ว (พอร์ต) และ timeout ถูกทำให้ปรากฏเป็น exception ความระมัดระวังจะพังทันทีที่เหนื่อยล้า ต่อเมื่อทำให้เป็นรูปแบบที่พังไม่ได้เท่านั้น “ไม่มีวันพังอีก” ถึงจะเป็นการรับประกันได้จริง

ข้อมูลที่สำคัญยิ่งควรฝากการปกป้องไว้กับความใส่ใจของตัวเองให้น้อยที่สุด สร้างใหม่เป็น โครงสร้างที่ไม่พังแม้จะถูกแตะพร้อมกัน — ต้องทำพังไปสองครั้งกว่าจะซึมซับสิ่งนี้ได้จริง


บทความที่เกี่ยวข้อง

ส่งข้อความได้ตามสบาย

ไม่ว่าจะเป็นการว่าจ้างงาน แนะนำโปรเจกต์ ความคิดเห็น หรือคำถาม ยินดีรับทั้งหมด ผมหวังเป็นอย่างยิ่งว่าจะได้เชื่อมต่อกับผู้ที่มีอุดมการณ์อันสูงส่งเช่นเดียวกัน ผมจะมุ่งมั่นท้าทายในสิ่งที่ทุ่มเททั้งชีวิตต่อไป ขอขอบคุณและฝากเนื้อฝากตัวด้วยครับ