Trong hai bài đầu tiên của series, chúng ta đã tìm hiểu về nền tảng tối ưu hóa cơ sở dữ liệu và cách phân tích, tối ưu câu truy vấn SQL. Bài viết này sẽ đi sâu vào một trong những công cụ mạnh mẽ nhất để tối ưu hiệu năng cơ sở dữ liệu: Index và các chiến lược indexing chuyên sâu.
Index có thể được ví như mục lục của một cuốn sách - thay vì phải đọc từng trang để tìm thông tin, bạn có thể nhanh chóng tra cứu vị trí chính xác. Tuy nhiên, việc thiết kế và sử dụng index hiệu quả đòi hỏi hiểu biết sâu sắc về cơ chế hoạt động và các chiến lược phù hợp với từng use-case cụ thể.
Cơ chế hoạt động của các loại index
Để sử dụng index hiệu quả, chúng ta cần hiểu cách chúng hoạt động bên dưới.
Cấu trúc dữ liệu của index
Hầu hết các hệ quản trị cơ sở dữ liệu sử dụng một trong các cấu trúc dữ liệu sau cho index:
- B-Tree (Balanced Tree): Phổ biến nhất, phù hợp cho các operations như =, >, <, BETWEEN, LIKE (không có wildcard đầu chuỗi)
- Hash Index: Cực kỳ nhanh cho exact match (=), nhưng không hỗ trợ range queries
- R-Tree: Chuyên dụng cho spatial data (địa lý, hình học)
- GiST (Generalized Search Tree): Hỗ trợ nhiều loại dữ liệu phức tạp (PostgreSQL)
- Inverted Index: Sử dụng cho full-text search
Hãy xem xét cách B-Tree hoạt động, vì đây là loại phổ biến nhất:
Nguyên lý cơ bản của index
Index trong cơ sở dữ liệu tương tự như mục lục trong sách - giúp tìm kiếm thông tin nhanh chóng mà không cần duyệt qua toàn bộ nội dung. Về mặt kỹ thuật, index là cấu trúc dữ liệu được tối ưu cho việc tìm kiếm, thường được tổ chức dưới dạng B-tree, B+tree, Hash, hoặc các cấu trúc chuyên biệt khác.
Khi một truy vấn được thực hiện trên cột có index, database engine sẽ:
- Tìm kiếm trong cấu trúc index để xác định vị trí của dữ liệu
- Truy cập trực tiếp đến vị trí đó trong bảng
- Lấy dữ liệu cần thiết
So với full table scan (quét toàn bộ bảng), việc sử dụng index có thể giảm thời gian truy vấn từ O(n) xuống O(log n) hoặc thậm chí O(1) trong trường hợp hash index.
Các loại index phổ biến
1. B-tree và B+tree Index
Đây là loại index phổ biến nhất, được hỗ trợ bởi hầu hết các hệ quản trị cơ sở dữ liệu:
- B-tree: Cấu trúc cây cân bằng, mỗi node chứa nhiều khóa và con trỏ
- B+tree: Biến thể của B-tree, tất cả dữ liệu đều nằm ở leaf nodes và được liên kết với nhau
-- Tạo B-tree index trong MySQL/PostgreSQL
CREATE INDEX idx_users_email ON users(email);
Đặc điểm:
- Hiệu quả cho các truy vấn range (>, <, BETWEEN)
- Hỗ trợ tốt cho ORDER BY và GROUP BY
- Tự cân bằng khi dữ liệu thay đổi
2. Hash Index
Hash index sử dụng hàm băm để ánh xạ giá trị khóa vào bucket cụ thể:
-- MySQL Memory storage engine hỗ trợ hash index
CREATE TABLE cache (
id INT NOT NULL,
data VARCHAR(100) NOT NULL,
PRIMARY KEY USING HASH (id)
) ENGINE=MEMORY;
-- PostgreSQL
CREATE INDEX idx_users_email_hash ON users USING HASH (email);
Đặc điểm:
- Cực kỳ nhanh cho exact match queries (=)
- Không hỗ trợ range queries (>, <, BETWEEN)
- Không hỗ trợ ORDER BY
- Thường được sử dụng trong memory tables hoặc NoSQL databases
3. Clustered Index
Clustered index quyết định cách dữ liệu được lưu trữ vật lý trong bảng:
-- MySQL/InnoDB: Primary key luôn là clustered index
CREATE TABLE orders (
id INT PRIMARY KEY, -- Đây là clustered index
customer_id INT,
order_date DATE
);
-- SQL Server: Chỉ định clustered index
CREATE CLUSTERED INDEX idx_orders_date ON orders(order_date);
Đặc điểm:
- Mỗi bảng chỉ có một clustered index
- Dữ liệu được sắp xếp vật lý theo clustered index
- Truy vấn theo clustered index thường nhanh hơn non-clustered
- Trong MySQL/InnoDB, primary key mặc định là clustered index
4. Non-Clustered Index
Non-clustered index lưu trữ giá trị khóa và con trỏ đến dữ liệu thực:
-- Tạo non-clustered index
CREATE INDEX idx_orders_customer ON orders(customer_id);
Đặc điểm:
- Một bảng có thể có nhiều non-clustered index
- Cần thêm bước lookup để lấy dữ liệu đầy đủ
- Tốn không gian lưu trữ hơn clustered index
5. Covering Index
Covering index là index chứa tất cả các cột cần thiết cho truy vấn, giúp tránh table lookup:
-- Tạo covering index cho truy vấn phổ biến
CREATE INDEX idx_orders_customer_date ON orders(customer_id, order_date);
-- Truy vấn sử dụng covering index (không cần table lookup)
SELECT customer_id, order_date FROM orders WHERE customer_id = 123;
Đặc điểm:
- Cực kỳ hiệu quả vì tránh được table lookup
- Tốn nhiều không gian hơn index thông thường
- Cần cân nhắc giữa hiệu năng và chi phí lưu trữ
Cấu trúc bên trong của index
Để hiểu sâu hơn, hãy xem xét cấu trúc bên trong của B+tree index (phổ biến trong MySQL/InnoDB và PostgreSQL):
- Root node: Node gốc của cây
- Internal nodes: Các node trung gian chứa khóa và con trỏ
- Leaf nodes: Các node lá chứa khóa và con trỏ đến dữ liệu thực (hoặc chính dữ liệu trong trường hợp clustered index)
Khi tìm kiếm một giá trị, database engine sẽ:
- Bắt đầu từ root node
- So sánh giá trị cần tìm với các khóa trong node
- Đi xuống internal node phù hợp
- Lặp lại quá trình cho đến khi đến leaf node
- Tìm giá trị chính xác trong leaf node
- Truy cập dữ liệu thực (nếu cần)
Quá trình này có độ phức tạp O(log n), giúp tìm kiếm nhanh chóng ngay cả với dữ liệu lớn.
Thiết kế, quản lý và bảo trì index hiệu quả
Việc thiết kế index tốt đòi hỏi sự cân nhắc kỹ lưỡng về workload, query patterns và tradeoffs.
Nguyên tắc thiết kế index hiệu quả
1. Phân tích query patterns
Trước khi tạo index, cần phân tích các truy vấn thường xuyên sử dụng:
-- MySQL: Xem các truy vấn phổ biến từ slow query log
SELECT query, count_star, avg_latency
FROM performance_schema.events_statements_summary_by_digest
ORDER BY count_star DESC
LIMIT 10;
-- PostgreSQL: Sử dụng pg_stat_statements
SELECT query, calls, total_time, rows
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 10;
2. Chọn cột phù hợp cho index
Các nguyên tắc chọn cột:
- Cột xuất hiện trong WHERE, JOIN, ORDER BY, GROUP BY
- Cột có tính chọn lọc cao (high selectivity)
- Cột có kích thước nhỏ (để giảm kích thước index)
-- Tốt: Cột có tính chọn lọc cao
CREATE INDEX idx_users_email ON users(email); -- email thường unique
-- Kém hiệu quả: Cột có tính chọn lọc thấp
CREATE INDEX idx_users_status ON users(status); -- status có thể chỉ có vài giá trị
3. Thiết kế composite index
Composite index (index trên nhiều cột) cần được thiết kế cẩn thận:
-- Tạo composite index
CREATE INDEX idx_orders_customer_date ON orders(customer_id, order_date);
Quy tắc quan trọng:
- Thứ tự cột: Đặt cột có tính chọn lọc cao nhất hoặc cột thường xuyên sử dụng trong điều kiện equality (=) lên đầu
- Leftmost prefix: Index chỉ được sử dụng từ trái sang phải, không thể bỏ qua cột
- Column order vs sort order: Thứ tự cột trong index ảnh hưởng đến khả năng sử dụng cho ORDER BY
Ví dụ về leftmost prefix:
- Index trên (a, b, c) có thể được sử dụng cho truy vấn với điều kiện:
- a = ?
- a = ? AND b = ?
- a = ? AND b = ? AND c = ?
- a = ? AND b > ?
- Nhưng KHÔNG hiệu quả cho:
- b = ?
- c = ?
- b = ? AND c = ?
4. Cân nhắc tradeoffs
Mỗi index đều có chi phí:
- Tăng không gian lưu trữ
- Chậm hơn khi INSERT, UPDATE, DELETE
- Tăng thời gian backup và restore
- Tăng phức tạp trong quản lý
Cần cân nhắc giữa lợi ích truy vấn và chi phí bảo trì.
Quản lý và bảo trì index
1. Giám sát sử dụng index
Định kỳ kiểm tra index nào đang được sử dụng và index nào không:
-- MySQL: Kiểm tra index usage
SELECT table_name, index_name, stat_value
FROM mysql.innodb_index_stats
WHERE stat_name = 'n_leaf_pages'
ORDER BY stat_value DESC;
-- PostgreSQL: Kiểm tra index usage
SELECT schemaname, relname, indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;
2. Xác định index dư thừa
Index dư thừa tốn không gian và làm chậm các thao tác ghi:
-- MySQL: Tìm index dư thừa tiềm năng
SELECT table_schema, table_name, redundant_index_name, dominant_index_name
FROM sys.schema_redundant_indexes;
-- PostgreSQL: So sánh index thủ công
SELECT
i1.indrelid::regclass AS table_name,
i1.indexrelid::regclass AS index1,
i2.indexrelid::regclass AS index2
FROM
pg_index i1
JOIN
pg_index i2 ON i1.indrelid = i2.indrelid AND i1.indexrelid < i2.indexrelid
WHERE
i1.indkey @> i2.indkey OR i2.indkey @> i1.indkey;
3. Rebuild và reindex
Theo thời gian, index có thể bị phân mảnh, giảm hiệu năng:
-- MySQL: Rebuild index
ALTER TABLE orders REORGANIZE PARTITION;
-- hoặc
OPTIMIZE TABLE orders;
-- PostgreSQL: Reindex
REINDEX TABLE orders;
-- hoặc
REINDEX INDEX idx_orders_customer;
4. Index statistics
Database engine sử dụng thống kê để quyết định có sử dụng index hay không:
-- MySQL: Cập nhật statistics
ANALYZE TABLE orders;
-- PostgreSQL: Cập nhật statistics
ANALYZE orders;
Index chuyên biệt (spatial, full-text, composite)
Ngoài các index cơ bản, các loại index chuyên biệt giúp tối ưu cho các use-case cụ thể.
1. Spatial Index
Spatial index tối ưu cho dữ liệu không gian như tọa độ, hình học:
-- MySQL: Tạo spatial index
CREATE TABLE locations (
id INT PRIMARY KEY,
name VARCHAR(100),
location POINT NOT NULL,
SPATIAL INDEX idx_location (location)
);
-- Truy vấn sử dụng spatial index
SELECT id, name, ST_AsText(location)
FROM locations
WHERE ST_Contains(
ST_GeomFromText('POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))'),
location
);
-- PostgreSQL: Sử dụng PostGIS extension
CREATE EXTENSION postgis;
CREATE TABLE locations (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
location GEOMETRY(Point, 4326)
);
CREATE INDEX idx_locations_gist ON locations USING GIST(location);
Đặc điểm:
- Tối ưu cho các truy vấn không gian (gần, trong, giao nhau)
- Thường sử dụng R-tree hoặc các biến thể
- Hữu ích cho ứng dụng GIS, bản đồ, location-based services
2. Full-Text Index
Full-text index tối ưu cho tìm kiếm văn bản:
-- MySQL: Tạo full-text index
CREATE TABLE articles (
id INT PRIMARY KEY,
title VARCHAR(200),
content TEXT,
FULLTEXT INDEX idx_content (title, content)
);
-- Tìm kiếm sử dụng full-text index
SELECT id, title
FROM articles
WHERE MATCH(title, content) AGAINST('database optimization' IN BOOLEAN MODE);
-- PostgreSQL: Sử dụng tsvector và GIN index
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title VARCHAR(200),
content TEXT,
content_tsv TSVECTOR
);
CREATE INDEX idx_articles_content ON articles USING GIN(content_tsv);
-- Tạo trigger để cập nhật content_tsv
CREATE FUNCTION articles_trigger() RETURNS trigger AS $$
BEGIN
NEW.content_tsv :=
setweight(to_tsvector('english', NEW.title), 'A') ||
setweight(to_tsvector('english', NEW.content), 'B');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE TRIGGER tsvector_update BEFORE INSERT OR UPDATE
ON articles FOR EACH ROW EXECUTE FUNCTION articles_trigger();
Đặc điểm:
- Hỗ trợ tìm kiếm theo từ khóa, phrase, boolean operators
- Xử lý stemming, stopwords, ranking
- Hiệu quả hơn nhiều so với LIKE ‘%keyword%’
3. Functional Index
Functional index tạo trên kết quả của hàm hoặc biểu thức:
-- PostgreSQL: Tạo functional index
CREATE INDEX idx_users_lower_email ON users(LOWER(email));
-- Truy vấn case-insensitive sử dụng index
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
-- MySQL 8.0+: Functional index
CREATE INDEX idx_users_lower_email ON users((LOWER(email)));
Đặc điểm:
- Cho phép index trên kết quả của hàm
- Hữu ích cho tìm kiếm case-insensitive, date extraction, calculations
- Có thể thay thế cho computed columns trong một số trường hợp
4. Partial Index
Partial index chỉ index một phần dữ liệu thỏa mãn điều kiện:
-- PostgreSQL: Tạo partial index
CREATE INDEX idx_orders_completed ON orders(order_date)
WHERE status = 'completed';
-- Truy vấn sử dụng partial index
SELECT * FROM orders
WHERE status = 'completed' AND order_date > '2023-01-01';
-- MySQL 8.0+: Tương tự với invisible index và filtered index
Đặc điểm:
- Nhỏ hơn và hiệu quả hơn so với index toàn bộ
- Tối ưu cho trường hợp truy vấn thường xuyên trên subset của dữ liệu
- Giảm overhead cho INSERT, UPDATE, DELETE
5. Bitmap Index
Bitmap index sử dụng bitmap để đại diện cho sự hiện diện của giá trị:
-- Oracle: Tạo bitmap index
CREATE BITMAP INDEX idx_orders_status ON orders(status);
-- PostgreSQL: Không hỗ trợ trực tiếp bitmap index, nhưng có thể sử dụng bitmap scan
Đặc điểm:
- Hiệu quả cho cột có số lượng giá trị khác nhau thấp (low cardinality)
- Tối ưu cho OLAP, data warehousing
- Hiệu quả cho các phép toán AND, OR, NOT
- Không phù hợp cho OLTP với nhiều thao tác ghi
Chiến lược indexing cho các use-case phổ biến
Mỗi loại ứng dụng có pattern truy vấn khác nhau, đòi hỏi chiến lược indexing phù hợp.
1. OLTP (Online Transaction Processing)
OLTP đặc trưng bởi nhiều giao dịch nhỏ, đọc/ghi cân bằng:
-- Chiến lược indexing cho OLTP
-- 1. Index trên primary key và foreign keys
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
order_date DATETIME,
status VARCHAR(20),
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
CREATE INDEX idx_orders_customer ON orders(customer_id);
-- 2. Index cho các truy vấn lookup phổ biến
CREATE INDEX idx_orders_date_status ON orders(order_date, status);
-- 3. Covering index cho các truy vấn hot
CREATE INDEX idx_orders_status_date_customer ON orders(status, order_date, customer_id);
Nguyên tắc:
- Ưu tiên selective indexes
- Cân bằng giữa hiệu năng đọc và ghi
- Tránh over-indexing
- Sử dụng covering index cho các truy vấn hot
2. OLAP (Online Analytical Processing)
OLAP đặc trưng bởi các truy vấn phức tạp, tổng hợp trên dữ liệu lớn:
-- Chiến lược indexing cho OLAP
-- 1. Index trên các cột dimension thường xuyên filter
CREATE INDEX idx_sales_product ON sales(product_id);
CREATE INDEX idx_sales_date ON sales(sale_date);
CREATE INDEX idx_sales_region ON sales(region_id);
-- 2. Bitmap index cho low-cardinality columns
-- (Oracle)
CREATE BITMAP INDEX idx_sales_payment_method ON sales(payment_method);
-- 3. Materialized views với index
CREATE MATERIALIZED VIEW mv_sales_by_product AS
SELECT product_id, SUM(amount) as total_sales
FROM sales
GROUP BY product_id;
CREATE INDEX idx_mv_sales_product ON mv_sales_by_product(product_id);
Nguyên tắc:
- Ưu tiên hiệu năng đọc hơn ghi
- Sử dụng bitmap index cho low-cardinality columns
- Cân nhắc materialized views
- Partitioning kết hợp với indexing
3. E-commerce
E-commerce có pattern truy vấn đặc thù như tìm kiếm sản phẩm, lọc, sắp xếp:
-- Chiến lược indexing cho e-commerce
-- 1. Product search và filtering
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(200),
description TEXT,
price DECIMAL(10,2),
category_id INT,
brand_id INT,
created_at TIMESTAMP,
is_active BOOLEAN
);
-- Full-text search
CREATE FULLTEXT INDEX idx_products_search ON products(name, description);
-- Filtering và sorting
CREATE INDEX idx_products_category_price ON products(category_id, price);
CREATE INDEX idx_products_brand_price ON products(brand_id, price);
CREATE INDEX idx_products_active_created ON products(is_active, created_at);
-- 2. Order management
CREATE INDEX idx_orders_customer_date ON orders(customer_id, order_date);
CREATE INDEX idx_order_items_order ON order_items(order_id);
CREATE INDEX idx_order_items_product ON order_items(product_id);
Nguyên tắc:
- Tối ưu cho full-text search
- Index cho các pattern filter và sort phổ biến
- Covering index cho các API hot
- Cân nhắc denormalization và pre-aggregation
4. Time-series data
Dữ liệu time-series có đặc thù là insert theo thời gian và query theo time range:
-- Chiến lược indexing cho time-series
-- 1. Partitioning theo thời gian
CREATE TABLE metrics (
id BIGINT PRIMARY KEY,
device_id INT,
metric_type VARCHAR(50),
value FLOAT,
timestamp TIMESTAMP
) PARTITION BY RANGE (timestamp);
CREATE TABLE metrics_2023_q1 PARTITION OF metrics
FOR VALUES FROM ('2023-01-01') TO ('2023-04-01');
CREATE TABLE metrics_2023_q2 PARTITION OF metrics
FOR VALUES FROM ('2023-04-01') TO ('2023-07-01');
-- 2. Index trên timestamp và device_id
CREATE INDEX idx_metrics_device_time ON metrics(device_id, timestamp);
-- 3. Specialized time-series database (TimescaleDB for PostgreSQL)
CREATE EXTENSION IF NOT EXISTS timescaledb;
SELECT create_hypertable('metrics', 'timestamp');
Nguyên tắc:
- Partitioning theo thời gian
- Index trên (device/entity, timestamp)
- Cân nhắc specialized time-series database
- Downsampling và pre-aggregation
5. Social networks
Social networks có pattern truy vấn phức tạp như feed, graph traversal:
-- Chiến lược indexing cho social networks
-- 1. User relationships
CREATE TABLE friendships (
user_id INT,
friend_id INT,
created_at TIMESTAMP,
status VARCHAR(20),
PRIMARY KEY (user_id, friend_id)
);
CREATE INDEX idx_friendships_friend_user ON friendships(friend_id, user_id);
CREATE INDEX idx_friendships_user_status ON friendships(user_id, status);
-- 2. Posts và feed
CREATE TABLE posts (
id BIGINT PRIMARY KEY,
user_id INT,
content TEXT,
created_at TIMESTAMP
);
CREATE INDEX idx_posts_user_time ON posts(user_id, created_at DESC);
-- 3. Likes và interactions
CREATE TABLE likes (
user_id INT,
post_id BIGINT,
created_at TIMESTAMP,
PRIMARY KEY (user_id, post_id)
);
CREATE INDEX idx_likes_post_time ON likes(post_id, created_at);
Nguyên tắc:
- Index cho graph traversal
- Composite index với DESC order cho feed
- Cân nhắc denormalization cho feed generation
- Cân nhắc specialized graph databases
Case Study: Tối ưu indexing trong ứng dụng thực tế
Hãy xem xét một case study về tối ưu indexing trong hệ thống e-commerce:
Vấn đề ban đầu
Một trang e-commerce đang gặp vấn đề hiệu năng với các tính năng:
- Tìm kiếm sản phẩm chậm
- Lọc và sắp xếp sản phẩm không hiệu quả
- Trang chi tiết đơn hàng tải chậm
- Báo cáo bán hàng mất nhiều thời gian
Phân tích workload
-- Các truy vấn hot
-- 1. Tìm kiếm sản phẩm
SELECT p.*, c.name as category_name
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE p.name LIKE '%smartphone%'
AND p.price BETWEEN 100 AND 500
AND p.is_active = 1
ORDER BY p.created_at DESC
LIMIT 20;
-- 2. Chi tiết đơn hàng
SELECT o.*, oi.*, p.name as product_name
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = 12345;
-- 3. Báo cáo bán hàng
SELECT p.category_id, c.name,
SUM(oi.quantity) as total_quantity,
SUM(oi.quantity * oi.price) as total_sales
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN categories c ON p.category_id = c.id
JOIN orders o ON oi.order_id = o.id
WHERE o.status = 'completed'
AND o.order_date BETWEEN '2023-01-01' AND '2023-03-31'
GROUP BY p.category_id, c.name
ORDER BY total_sales DESC;
Chiến lược indexing
Bước 1: Tối ưu tìm kiếm sản phẩm
-- Thay thế LIKE với full-text search
CREATE FULLTEXT INDEX idx_products_name_desc ON products(name, description);
-- Index cho filtering và sorting
CREATE INDEX idx_products_active_category_price ON products(is_active, category_id, price);
CREATE INDEX idx_products_active_created ON products(is_active, created_at);
-- Truy vấn tối ưu
SELECT p.*, c.name as category_name
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE MATCH(p.name, p.description) AGAINST('smartphone' IN BOOLEAN MODE)
AND p.price BETWEEN 100 AND 500
AND p.is_active = 1
ORDER BY p.created_at DESC
LIMIT 20;
Bước 2: Tối ưu chi tiết đơn hàng
-- Index cho foreign keys
CREATE INDEX idx_order_items_order ON order_items(order_id);
CREATE INDEX idx_order_items_product ON order_items(product_id);
-- Covering index cho thông tin thường xuyên truy vấn
CREATE INDEX idx_orders_detail ON orders(id, customer_id, status, total_amount);
Bước 3: Tối ưu báo cáo bán hàng
-- Index cho reporting queries
CREATE INDEX idx_orders_status_date ON orders(status, order_date);
CREATE INDEX idx_order_items_order_product ON order_items(order_id, product_id);
-- Materialized view cho báo cáo
CREATE MATERIALIZED VIEW mv_sales_by_category AS
SELECT p.category_id, c.name,
SUM(oi.quantity) as total_quantity,
SUM(oi.quantity * oi.price) as total_sales,
DATE_TRUNC('month', o.order_date) as month
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN categories c ON p.category_id = c.id
JOIN orders o ON oi.order_id = o.id
WHERE o.status = 'completed'
AND o.order_date BETWEEN '2023-01-01' AND '2023-03-31'
GROUP BY p.category_id, c.name
ORDER BY total_sales DESC;