ในยุคที่ข้อมูลคือทรัพยากรที่มีค่าที่สุดขององค์กร ระบบ Tardis ซึ่งเป็นแพลตฟอร์มบันทึกประวัติการทำงาน (History Tracking System) กลายเป็นหัวใจสำคัญของหลายธุรกิจ ไม่ว่าจะเป็นระบบ E-commerce ที่ต้องเก็บประวัติการสั่งซื้อและพฤติกรรมลูกค้า ระบบ RAG องค์กรที่ต้องจัดการ Embeddings จำนวนมหาศาล หรือโปรเจกต์ของนักพัฒนาอิสระที่ต้องการระบบ Analytics ในราคาที่เข้าถึงได้
บทความนี้จะพาคุณเจาะลึกการเปรียบเทียบ 3 วิธีการจัดเก็บข้อมูลประวัติที่ได้รับความนิยมสูงสุดในปี 2024-2025 พร้อมตารางเปรียบเทียบที่ครอบคลุม ตัวอย่างโค้ดที่พร้อมใช้งานจริง และบทวิเคราะห์จากประสบการณ์ตรงของทีมพัฒนาระบบ E-commerce ขนาดใหญ่ที่ต้องจัดการข้อมูลมากกว่า 10 ล้าน Event ต่อวัน
Tardis History Data คืออะไร และทำไมต้องสนใจ
ระบบ Tardis (Time-series Application with Rich Data Including Statistics) เป็นรูปแบบการออกแบบระบบที่เน้นการจัดเก็บข้อมูลเหตุการณ์ (Event) ตามลำดับเวลา ตัวอย่างการใช้งานจริงที่พบบ่อย ได้แก่
- E-commerce CRM: บันทึกทุกการโต้ตอบของลูกค้า ตั้งแต่การคลิกเข้าชม การเพิ่มสินค้าลงตะกร้า การสั่งซื้อ จนถึงการรีวิว ข้อมูลเหล่านี้นำมาใช้วิเคราะห์ Customer Lifetime Value และทำ Personalized Recommendation
- Enterprise RAG System: ระบบ Retrieval-Augmented Generation ที่ต้องจัดเก็บ Document Embeddings พร้อม Metadata เพื่อให้ AI สามารถค้นหาข้อมูลที่เกี่ยวข้องได้อย่างแม่นยำ
- Developer Analytics: นักพัฒนาอิสระที่สร้าง SaaS ต้องการ Analytics Dashboard ที่ช่วยเข้าใจพฤติกรรมผู้ใช้ โดยไม่ต้องจ่ายค่าใช้จ่าย Analytics Platform แพงๆ
ความท้าทายหลักคือ ข้อมูลเหล่านี้มีปริมาณเพิ่มขึ้นอย่างมหาศาล ระบบหนึ่งอาจต้องจัดการข้อมูลหลายร้อย GB ต่อวัน และต้องสามารถ Query ได้อย่างรวดเร็ว (sub-second) เพื่อรองรับการใช้งาน Real-time
เปรียบเทียบ 3 วิธีการจัดเก็บข้อมูล Tardis History
1. Apache Parquet — รูปแบบไฟล์คอลัมน์ที่เหมาะกับ Data Lake
Apache Parquet เป็นรูปแบบไฟล์แบบ Column-oriented ที่พัฒนาโดย Twitter และ Apache Software Foundation มีจุดเด่นเรื่องการบีบอัดที่มีประสิทธิภาพสูงและการรองรับ Schema Evolution ทำให้เหมาะกับการจัดเก็บข้อมูลใน Data Lake หรือ Object Storage อย่าง S3, GCS
ข้อดี:
- ขนาดไฟล์เล็กกว่า CSV ถึง 75% ด้วยการบีบอัดแบบ Column-oriented
- รองรับ Predicate Pushdown ทำให้ Query เร็วขึ้นโดยอ่านเฉพาะคอลัมน์ที่ต้องการ
- รวมได้กับระบบ Big Data หลากหลาย เช่น Spark, Presto, Athena
- เป็น Open-source ไม่มีค่าใช้จ่ายในการ License
ข้อเสีย:
- ไม่รองรับ Real-time Write ต้อง Batch เขียนข้อมูล
- ไม่มี Index หรือ Partitioning ในตัว ต้องจัดการเอง
- ไม่เหมาะกับ OLTP Workload ที่ต้องการ Update/Delete บ่อย
2. ClickHouse — OLAP Database ที่เร็วที่สุดในตลาด
ClickHouse คือ Column-oriented DBMS ที่พัฒนาโดย Yandex ซึ่งเป็นบริษัท Search Engine ยักษ์ใหญ่ของรัสเซีย ถูกออกแบบมาเพื่อ OLAP (Online Analytical Processing) โดยเฉพาะ สามารถ Query ข้อมูลหลายพันล้าน Rows ได้ในเวลาไม่กี่วินาที
ข้อดี:
- ประสิทธิภาพ Query เร็วที่สุดในกลุ่ม OLAP Database
- รองรับ Horizontal Scaling ได้ง่าย
- มีฟีเจอร์ Materialized View สำหรับ Pre-aggregation
- รองรับ Real-time Insert ด้วย MergeTree Engine
ข้อเสีย:
- ต้องมีความรู้ด้าน Database Administration
- ค่าใช้จ่ายในการ Deploy และ Maintain สูง
- ไม่เหมาะกับ Transaction ที่ซับซ้อน
3. DuckDB — In-Process Analytics Database
DuckDB เป็น Embedded OLAP Database ที่ทำงานใน Process เดียวกับ Application ไม่ต้องมี Server แยกต่างหาก ถูกออกแบบมาเพื่อให้เป็น SQLite ของ Analytics ที่รองรับ SQL เต็มรูปแบบ
ข้อดี:
- ไม่ต้อง Setup Server ใช้งานง่ายมาก
- ประสิทธิภาพดีเยี่ยมสำหรับ Dataset ขนาดกลาง (ไม่เกิน 100GB)
- รองรับ Pandas DataFrame Integration อย่างลื่นไหล
- เป็น Open-source และ Cross-platform
ข้อเสีย:
- ไม่รองรับ Concurrent Access จากหลาย Process
- ไม่เหมาะกับ Production System ที่มี User จำนวนมาก
- ข้อมูลจำกัดอยู่ที่ขนาดของ RAM
ตารางเปรียบเทียบระบบจัดเก็บ Tardis History
| เกณฑ์การเปรียบเทียบ | Apache Parquet | ClickHouse | DuckDB |
|---|---|---|---|
| รูปแบบ | File Format | OLAP Database | Embedded DB |
| ความเร็ว Query | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| ความเร็ว Write | ⭐⭐ (Batch) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| ความง่ายในการติดตั้ง | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| ค่าใช้จ่าย Infrastructure | ต่ำ | สูง | ต่ำมาก |
| Concurrent Access | จำกัด | สูงมาก | ไม่รองรับ |
| รองรับ Data Volume | Unlimited (PB) | Unlimited | จำกัด (RAM) |
| SQL Support | ผ่าน Engine | เต็มรูปแบบ | เต็มรูปแบบ |
| Real-time Capability | ❌ | ✅ | ⚠️ จำกัด |
เหมาะกับใคร / ไม่เหมาะกับใคร
Apache Parquet
✅ เหมาะกับ:
- ทีมที่มี Data Lake อยู่แล้วบน AWS S3, Google GCS หรือ Azure Blob
- งาน ETL/Batch Processing ที่รัน Nightly Jobs
- องค์กรที่ใช้ Spark หรือ Databricks อยู่แล้ว
- โปรเจกต์ที่ต้องการลดค่าใช้จ่าย Storage ให้ต่ำที่สุด
❌ ไม่เหมาะกับ:
- ระบบที่ต้องการ Real-time Analytics
- ทีมที่ไม่มีความรู้ด้าน Big Data Stack
- Application ที่ต้องการ Interactive Query
ClickHouse
✅ เหมาะกับ:
- องค์กรขนาดใหญ่ที่มีทีม DevOps/DBA เฉพาะทาง
- ระบบที่ต้องรองรับ User พร้อมกันหลายร้อยคน
- E-commerce Platform ที่มี Event มากกว่า 1 ล้านรายการต่อวัน
- ทีมที่ต้องการ SLA ที่ชัดเจนและต้องการ Support จากผู้เชี่ยวชาญ
❌ ไม่เหมาะกับ:
- Startup หรือ Small Team ที่มีงบประมาณจำกัด
- โปรเจกต์ Prototype หรือ MVP
- นักพัฒนาอิสระที่ต้องการความง่ายในการ Deploy
DuckDB
✅ เหมาะกับ:
- นักพัฒนาอิสระที่สร้าง SaaS ขนาดเล็ก-กลาง
- Data Analyst ที่ต้องการทำ Exploration บน Local Machine
- โปรเจกต์ที่มี Dataset ไม่เกิน 100GB
- ทีมที่ต้องการเริ่มต้นเร็วและไม่ต้องการ Infrastructure ซับซ้อน
❌ ไม่เหมาะกับ:
- ระบบ Production ที่ต้องรองรับ Traffic สูง
- องค์กรที่ต้องการ Multi-user Access
- งานที่ต้องการ Real-time Write จำนวนมาก
ตัวอย่างโค้ด: การใช้งานจริงในแต่ละวิธี
ตัวอย่างที่ 1: การบันทึก E-commerce Event ด้วย Parquet (Python)
import pyarrow as pa
import pyarrow.parquet as pq
from datetime import datetime
import os
class TardisParquetWriter:
"""ตัวอย่างการจัดเก็บ Event ด้วย Parquet - เหมาะกับ Batch Processing"""
def __init__(self, bucket_path: str):
self.bucket_path = bucket_path
self.schema = pa.schema([
('event_id', pa.string()),
('customer_id', pa.string()),
('event_type', pa.string()), # view, add_to_cart, purchase
('product_id', pa.string()),
('timestamp', pa.timestamp('us')),
('metadata', pa.map_(pa.string(), pa.string())),
])
def write_daily_events(self, events: list, date_str: str):
"""
บันทึก Event ของวันเดียวเป็นไฟล์ Parquet
แนะนำ: สร้าง Partition ตาม date/hour เพื่อ Query เร็วขึ้น
"""
table = pa.Table.from_pylist(events, schema=self.schema)
# บีบอัดด้วย ZSTD ซึ่งให้ Compression Ratio ดีที่สุด
writer = pq.ParquetWriter(
f"{self.bucket_path}/date={date_str}/events.parquet",
table.schema,
compression='zstd',
use_dictionary=True,
)
writer.write_table(table)
writer.close()
print(f"✅ บันทึก {len(events):,} events เรียบร้อย - ขนาด: {os.path.getsize(f'{self.bucket_path}/date={date_str}/events.parquet') / 1024 / 1024:.2f} MB")
def query_with_pyarrow(self, date_range: tuple):
"""Query ข้อมูลด้วย PyArrow - อ่านเฉพาะ Partition ที่ต้องการ"""
start_date, end_date = date_range
# ใช้ ParquetDataset เพื่ออ่านเฉพาะ Partition ที่ต้องการ
dataset = pq.ParquetDataset(
self.bucket_path,
filters=[('date', 'in', [start_date, end_date])]
)
table = dataset.read()
# แปลงเป็น Pandas สำหรับ Analysis
df = table.to_pandas()
return df
การใช้งาน
writer = TardisParquetWriter('s3://my-ecommerce-tardis/')
sample_events = [
{'event_id': 'evt_001', 'customer_id': 'cust_123', 'event_type': 'view',
'product_id': 'prod_456', 'timestamp': datetime.now(), 'metadata': {}},
# ... events อื่นๆ
]
writer.write_daily_events(sample_events, '2025-01-15')
ตัวอย่างที่ 2: การใช้งาน ClickHouse สำหรับ Real-time Analytics
from clickhouse_driver import Client
from datetime import datetime
import json
class TardisClickHouse:
"""ตัวอย่าง ClickHouse สำหรับ Real-time E-commerce Analytics"""
def __init__(self, host: str, port: int = 9000):
self.client = Client(host=host, port=port, database='tardis')
self._init_table()
def _init_table(self):
"""สร้าง MergeTree Table พร้อม Partition ตามเดือน"""
self.client.execute('''
CREATE TABLE IF NOT EXISTS customer_events (
event_id String,
customer_id String,
event_type Enum8('view' = 1, 'add_to_cart' = 2, 'purchase' = 3, 'refund' = 4),
product_id String,
price Decimal(10, 2),
quantity UInt32,
timestamp DateTime64(3, 'Asia/Bangkok'),
metadata JSON
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (customer_id, timestamp)
TTL timestamp + INTERVAL 12 MONTH
SETTINGS index_granularity = 8192
''')
def insert_events(self, events: list):
"""Insert แบบ Asynchronous สำหรับ Throughput สูง"""
# แปลงเป็น Tuple format ที่ ClickHouse เข้าใจ
data = [
(
e['event_id'],
e['customer_id'],
e['event_type'],
e['product_id'],
float(e.get('price', 0)),
int(e.get('quantity', 1)),
e['timestamp'],
json.dumps(e.get('metadata', {}))
)
for e in events
]
self.client.execute(
'INSERT INTO customer_events VALUES',
data,
types_check=True
)
def get_customer_journey(self, customer_id: str, days: int = 30):
"""ดึง Customer Journey พร้อม Funnel Analysis"""
result = self.client.query_dataframe('''
WITH
-- คำนวณ Session
arrayJoin(groupArray((timestamp, event_type, product_id))) AS events,
date_diff('minute', lag(timestamp) OVER w, timestamp) AS session_gap
SELECT
timestamp,
event_type,
product_id,
price,
quantity,
price * quantity AS revenue
FROM customer_events
WHERE customer_id = %(customer_id)s
AND timestamp > now() - INTERVAL %(days)s DAY
WINDOW w AS (PARTITION BY customer_id ORDER BY timestamp)
ORDER BY timestamp
''', params={'customer_id': customer_id, 'days': days})
return result
def get_daily_funnel(self, date: str):
"""Funnel Analysis รายวัน - ClickHouse ทำได้เร็วมาก"""
result = self.client.query_dataframe(f'''
SELECT
event_type,
count() AS count,
uniqExact(customer_id) AS unique_customers,
sum(price * quantity) AS revenue
FROM customer_events
WHERE date(timestamp) = '{date}'
GROUP BY event_type
ORDER BY count() DESC
''')
return result
การใช้งาน
tardis = TardisClickHouse('clickhouse.example.com')
tardis.insert_events([{
'event_id': 'evt_001',
'customer_id': 'cust_123',
'event_type': 'purchase',
'product_id': 'prod_456',
'price': 299.00,
'quantity': 2,
'timestamp': datetime.now(),
'metadata': {'campaign': 'summer_sale'}
}])
print(tardis.get_daily_funnel('2025-01-15'))
ตัวอย่างที่ 3: DuckDB สำหรับ Developer Analytics Dashboard
import duckdb
import pandas as pd
from datetime import datetime, timedelta
class TardisDuckDB:
"""DuckDB สำหรับ Developer SaaS - Embedded Analytics"""
def __init__(self, db_path: str = ':memory:'):
"""
ใช้ :memory: สำหรับ Testing
หรือระบุ Path เพื่อ Persist ข้อมูล
"""
self.con = duckdb.connect(db_path)
self._init_schema()
def _init_schema(self):
"""สร้าง Schema พร้อม Compression"""
self.con.execute('''
CREATE SEQUENCE IF NOT EXISTS event_id START 1;
CREATE TABLE IF NOT EXISTS user_events (
event_id BIGINT PRIMARY KEY DEFAULT nextval('event_id'),
user_id VARCHAR NOT NULL,
event_type VARCHAR NOT NULL,
page_path VARCHAR,
duration_ms INTEGER,
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- สร้าง Index สำหรับ Query ที่ใช้บ่อย
CREATE INDEX IF NOT EXISTS idx_user_events_user_id
ON user_events(user_id);
CREATE INDEX IF NOT EXISTS idx_user_events_created_at
ON user_events(created_at);
''')
# กดให้ DuckDB ใช้ Memory เยอะขึ้นเพื่อ Performance
self.con.execute("SET memory_limit='8GB'")
self.con.execute("SET threads=4")
def track_event(self, user_id: str, event_type: str,
page_path: str = None, duration_ms: int = None,
metadata: dict = None):
"""บันทึก Event แบบ Real-time"""
self.con.execute('''
INSERT INTO user_events (user_id, event_type, page_path, duration_ms, metadata)
VALUES (?, ?, ?, ?, ?)
''', [user_id, event_type, page_path, duration_ms,
duckdb.json(metadata) if metadata else None])
def get_user_cohort(self, start_date: datetime, end_date: datetime):
"""Cohort Analysis - ดูว่า User ใช้งานยังไงในแต่ละ Cohort"""
result = self.con.execute('''
WITH user_cohort AS (
SELECT
user_id,
DATE(MIN(created_at)) AS cohort_date,
COUNT(*) AS total_events,
SUM(duration_ms) AS total_duration
FROM user_events
WHERE created_at BETWEEN ? AND ?
GROUP BY user_id
)
SELECT
cohort_date,
COUNT(*) AS cohort_size,
AVG(total_events) AS avg_events_per_user,
AVG(total_duration) / 1000 AS avg_duration_sec
FROM user_cohort
GROUP BY cohort_date
ORDER BY cohort_date
''', [start_date, end_date]).df()
return result
def get_retention_matrix(self, cohort_month: str):
"""Retention Matrix - วิเคราะห์ว่า User กลับมาใช้งานอีกไหม"""
result = self.con.execute(f'''
WITH first_activity AS (
SELECT
user_id,
DATE_TRUNC('month', MIN(created_at))::VARCHAR AS first_month
FROM user_events
GROUP BY user_id
HAVING DATE_TRUNC('month', MIN(created_at))::VARCHAR = '{cohort_month}'
),
activity AS (
SELECT
user_id,
DATE_TRUNC('month', created_at)::VARCHAR AS activity_month
FROM user_events
)
SELECT
fa.first_month,