Chuyện của dev's blog

Hệ thống Tiki 3 năm trước như thế nào - P2

 07/10/2017  11102

Thời gian không chờ đợi ai. Thấm thoát đã ngần ấy năm ra trường rồi đi làm, dường như mình đã già để có thể tìm hiểu và chạy theo công nghệ.
Sau Phần 1 được khá nhiều bạn quan tâm, nay mình xin chia sẻ đến các bạn phần 2 của loạt bài "Hệ thống Tiki 3 năm trước như thế nào".
Kiến thức tuy cũ nhưng vẫn mới với một số bạn trẻ và nó cũng sẽ giúp các bạn giải quyết các vấn đề thường gặp hằng ngày khi xây dựng 1 hệ thống website lớn như Tiki nhé.

Các vấn đề và cách giải quyết

Những thao tác nặng trong trang Admin ảnh hưởng đến Database

Phải nói Magento xây dựng 1 trang Admin rất hoàng tráng và chi tiết. Nó như là 1 chuẩn thiết kế cho những trang ecommerce. (Quản lý Category, Product, Customer...)
Mặc định khi cài đặt Magento là có sẵn quá nhiều tính năng và hằng ngày tụi mình phải phát triển thêm các tính năng khác nữa, nên dần theo thời gian sẽ có những thao tác cực kì chậm và phải mất nhiều thời gian cho việc tìm hiểu và optimize lại. Các thao tác chậm này chủ yếu là việc Read data lên từ database.

Trong bài trước mình đã nói đến việc Database MySQL được cấu hình theo kiểu Master-Slave. Các data từ Master server sẽ được đồng bộ qua các Slave server.
Như vậy mình sẽ tách trang Admin ra 1 domain riêng và sử dụng riêng 1 Slave database server cho trang admin này.
Làm như vậy thì những thao tác Read data lên (SELECT * FROM ...) sẽ chỉ ảnh hưởng đến server này thôi, tránh ảnh hưởng đến các server còn lại.
Dĩ nhiên là mình vẫn phải cấu hình "Write Adapter" vào Master server như Frontend rồi.

Thao tác search trong trang Admin

Tất cả các field có dạng Text khi search thì Magento sẽ chạy câu query như sau:

SELECT * FROM <TABLE> WHERE <field> LIKE '%<keyword>%'

Vd mình search các product theo SKU = '123' thì Magento sẽ chạy câu query:

SELECT * FROM catalog_product_entity WHERE sku LIKE '%123%'

Thông thường thì user sẽ search đúng SKU luôn, vì vậy mặc dù column "sku" trong table "catalog_product_entity" đã được đánh Index, nhưng trong trường hợp này, MySQL sẽ không sử dụng index và phải scan hết các row trong table "catalog_product_entity".
Tùy theo cách sử dụng của user mà mình sẽ overwrite lại hàm search của Magento, để khi search theo SKU = '123' sẽ dùng chạy câu query như sau:

SELECT * FROM catalog_product_entity WHERE sku LIKE '123%'
// hoặc
SELECT * FROM catalog_product_entity WHERE sku = '123'

Xuất file CSV trong trang Admin

Mặc định khi xuất file CSV cho 1 list product trong database vd khoảng 1000 product thì Magento sẽ chạy câu query giống như vậy

SELECT * FROM catalog_product_entity ORDER BY created_at LIMIT 1000;

Việc select 1 lần nhiều data như vậy tốn rất nhiều resource của Database và nếu nhiều user cùng muốn xuất file như vậy thì sẽ làm hệ thống chậm đi rất nhiều.

Mình giải quyết bằng cách là viết 1 hàm xuất file CSV khác dạng stream. Chạy trong vòng lặp while, mỗi lần select ra 100 product thôi, select xong thì push data xuống Browser liền.

Sử dụng Queue

Để gửi Email hay Sms tới khách hàng thì Tiki sẽ dùng service thứ 3 bằng cách gọi API.
Mình lấy vd là trường hợp khi 1 user đăng kí thành công thì sẽ nhận được 1 email chúc mừng kèm theo thông tin tài khoản.

Trong Magento mình sẽ hook vào event customer_register_success. Khi event này xảy ra thì sẽ run đoạn code gửi HTTP request đến API để gửi email. Nhưng nếu service này chậm do chính nó hay do network thì user sẽ phải đợi rất lâu.

Có 2 cách giải quyết vấn đề này:

  • Cấu hình timeout HTTP request xuống 1 mức chấp nhận được khoảng 10s. Nếu fail thì ghi nhận lại và gửi sau
  • Sử dụng 1 Queue system (Gearman), push 1 job với data là { user_id : xxx, email: xxx }.
    Và run 1 script (worker) chạy background, đọc job data từ Queue lên và gọi HTTP request send email đi.
    Cách này cũng phải cấu hình timeout cho HTTP request và ghi nhận lại nếu fail để lần chạy sau sẽ gửi lại.

Khi dùng Queue thì cũng nên tách ra 2 Queue để chạy riêng list các email gửi fail và list các emai chưa được gửi.

Có thể chạy nhiều script background song song đọc job từ Queue và xử lý để tiết kiệm thời gian hơn, nhưng nên nhớ tùy vào số CPU core mà nên chạy bấy nhiều worker song song nhé!

Vì các worker script này chạy background trong 1 thời gian dài nên có khi bị đứng (không xử lý được nữa) hoặc memory sẽ tăng lên nếu viết không tốt, cho nên tốt nhất sau khi xử lý khoảng một số job nhất định thì gọi function "exit()" để tắt worker.
Và mình sẽ dùng 1 service của Linux là "supervisord" để quản lý các worker script này, khi nào tắt thì start lại.

Full page caching

Tất cả các page nào có thể cache được (như Homepage, Category list...) thì HTML content của page đó sẽ được đưa vào Memcached (để không phải query database để lấy các data cần thiết nữa, vì các page này 1 ngày cũng ít thay đổi) với:

  • key: URL
  • value: HTML content

Nhưng khi cache như vậy thì user nào cũng sẽ thấy nội dung giống nhau, nếu 1 user vừa login xong, trên Header sẽ phải hiển thị thông tin của user đó (vd Họ tên, số thông báo chưa đọc...) thì sẽ không hiển thị được.

Như vậy những phần nào hiển thị khác nhau cho mỗi user thì mình sẽ sử dụng Ajax để hiển thị cho đúng.

Để biết 1 page đã được cache hay chưa, vui lòng bấm F12 để mở Developer Tool trên browser lên, và xem response header của page đó:

  • tala-cache: HIT
  • x-cache: cached


Thấy có chữ "HIT" hay "cached" thì chắc là nội dung page này được lấy trong Memcached ra rồi :)
Còn page chưa được cache thì response header sẽ là:

  • x-cache: uncached
    Khi bạn refresh lại page thì content đã được cache rồi, nên lần này response header là:
  • x-cache: cached

Mình đoán vậy thôi vì trước là vậy, chứ hiện tại không biết ý nghĩa của "tala-cacha" và "x-cache" là gì nữa :)

Như các bạn đã biết thì Memcached sẽ cho cấu hình Memory limit vd 100MB, nếu data trong memory vượt quá size này thì Memcached sẽ remove bớt các key ít sử dụng đi.
Mà "URL" sử dụng để làm "key" thì có nghĩa là mình truyền param gì trên URL thì hệ thống sẽ lưu HTML content của URL đó vào Memcached và sau đó response về cho Browser.

Vd khi mình chọn Danh mục là "LEGO City" thì URL của page đó là https://tiki.vn/lego-city/c4446/lego?src=mega-menu
Khi bạn xem lần đầu thì respone header là "x-cache: uncached", lần 2 là "x-cache: cached".
Sau đó bạn nối thêm param gì đó ở phía sau URL này, vd https://tiki.vn/lego-city/c4446/lego?src=mega-menu&chuyencuadev thì respone header là "x-cache: uncached".

Redis Caching

Product

Data của 1 product bao gồm (sku, title, images, price) sẽ đươc Cache vào Redis sử dụng kiểu dữ liệu là HASH với function:

// HMSET key field value [field value ...]

public function setData($product, $data)
{
	if ($product && $product->getId() && $data) {
		Mage::app()->getCacheStorage()->hMSet('product::' . $product->getId(), $data);
	}
}

Đoạn code trên sẽ lưu $data của 1 product vào Redis với "key" kết hợp giữa 1 namespace và product ID.
Chọn kiểu dữ liệu để lưu là HASH vì phù hợp giữa dạng field-value trong Redis với associative array trong PHP.

Book author

Trước trang chi tiết của 1 quyển sách vd https://tiki.vn/cham-toi-giac-mo-phien-ban-bia-mem-p979858.html có hiển thị thêm một số thông tin tác giả (như tên...). Nên mình sẽ cache các thông tin này vào Redis để có thể sử dụng ở trang này và các chỗ khác.
Mình cũng sẽ chọn kiểu data để lưu là HASH nhưng vì data của 1 tác giả không nhiều nên mình sẽ lưu data của nhiều tác giả vào chung 1 key. Mỗi tác giả sẽ là 1 field trong key đó, value là data dạng serialize.

Data trong Redis sẽ có dạng như sau:

"author_bucket::0" => ["1" => "a:2:{s:2:"id";i:1;s:5:"title";s:20:"Chuyện của dev 1";}", "2" => "a:2:{s:2:"id";i:2;s:5:"title";s:20:"Chuyện của dev 2";}"]

Làm cách này sẽ tránh tạo nhiều key trong Redis, các tác giả có ID "giống nhau" sẽ được đưa vào chung 1 key:

protected function getBucketId($id)
{
	return floor($id / 100);
}

public function setData($authorId, $data)
{
	Mage::app()->getCacheStorage()->hSet(sprintf('author_bucket::%d', $this->getBucketId($authorId)), $authorId, serialize($data));
}

public function getData($authorId)
{
	$data = Mage::app()->getCacheStorage()->hGet(sprintf('author_bucket::%d', $this->getBucketId($authorId)), $authorId);

	if ($data) {
		return unserialize($data);
	}

	return false;
}

Notes: Khi sử dụng Cache thì nhớ phải xóa Cache khi thêm/xóa/sửa các data liên quan.

Lời kết

Bài viết này mình tập trung chia sẻ cho các bạn về Queue và Caching system. Phần 3 mình sẽ dành riêng để nói về Database vì có thể nó hơi dài so với các phần khác.
Các bạn có thắc mắc gì cứ comment bên dưới nhé. Have fun at #chuyencuadev.