Chuyện của dev's blog

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

 21/09/2017  15768

Gần đây mình lên Facebook thì thấy Tech Talk để nguyên cái banner màu xanh to đùng để giới thiệu event "How Tiki Made Dzựt Cô Hồn?".
Lướt qua các event khác thì mình cũng thấy diễn giả toàn là Director, Leader, Giám đốc phát triển, Solutions Architect...
Vậy thì ai sẽ là người code và fix bug khi có lỗi. Dĩ nhiên là mấy bạn dev phía dưới rồi!
Lúc này làm mình nhớ lại những lúc ngồi bàn bạc, suy nghĩ làm sao tối ưu được cái đống Source code mà có nhiều người cùng viết trên đó, và các vấn đề về performance cần phải giải quyết trên platform Magento nặng nề.
Vì vậy mình viết bài này để chia sẻ những kinh nghiệm khi làm việc trên Magento 1.x cho các bạn dev - Những người ngày đêm tạo nên các dòng code tuyệt vời nhất.

Tổng quan hệ thống

Mọi lời nói không thể thay thế bằng hình ảnh rõ ràng được :)

  • Hình trên là gợi ý về cách thiết kế hệ thống Magento để có thể đáp ứng được một lượng lớn user
  • Source code sẽ được deploy lên 1 số server Web app, server Media và request sẽ được load balance qua các server này
  • Database sử dụng là Percona MySQL được cấu hình Master-Slave (Data được đồng bộ từ Master server sang Slave server). Giữa các Slave server sẽ có 1 server khác làm nhiệm vụ Load balancing
  • 1 server Nginx riêng phục vụ cho các media (css,js,image,video) và với 1 domain khác như là "https://cdn.com"
  • 1 server để chạy các service khác như Redis, Memcache, Gearman

Bài viết này mình tập trung chia sẻ về các vấn đề mà đã gặp phải khi scale hệ thống Magento 1.x và cách giải quyết các vấn đề đó như thế nào.
Tuy nhiên các kiến thức trong này đều có thể áp dụng cho các project PHP khác nhé :)

Profile hệ thống

Trước khi bắt đầu tối ưu system thì mình phải biết những chỗ nào chậm và chậm do đâu.
Mình dựa vào 2 loại log để biết được những request và câu SQL nào chậm:

  • MySQL slow log: Cấu hình MySQL để log lại những câu query nào chậm quá 5s
  • Newrelic: Log lại tất cả các request (URL) và thời gian thực thi của từng request.

Sau khi đã biết request nào chậm rồi thì mình cần biết chi tiết hơn là function hay đoạn code nào chậm để debug trên local cho dễ.
Mình dùng 1 extension của PHP gọi là xhprof, nhưng có 1 project giúp mình xem report data được sinh ra từ extension này dễ hơn đó là https://github.com/perftools/xhgui
Và sau khi setup xong thì data sẽ được lưu trên MongDB và sau đó mình sẽ xem dễ dàng trên giao diện web:

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

Tạo hình thumbnail

Magento sẽ có 1 số page có rất nhiều hình thumbnail của các product. Mình lấy ví dụ là Homepage

Bạn sẽ thấy 4 hình thumbnail (Tivi) to, và bên phải là 3 hình thumbnail nhỏ hơn.
Trong Magento thì 1 product sẽ có 1 hình gốc, sẽ có 1 đoạn code để tạo ra các hình thumbnail với kích thước khác nhau để hiển thị ở các page hay các block khác nhau. Và link hình thumbnail sẽ có dạng "https://cdn.com/media/catalog/product/cache/1/image/200x200/9df78eab33525d08d6e5fb8d27136e95/3/5/351119_1.jpg"

  • https://cdn.com: Là base URL cho các file dạng media
  • media/catalog/product/cache/1/image/200x200/9df78eab33525d08d6e5fb8d27136e95/3/5: Đường dẫn thư mục chứa file thumbnail
  • 9df78eab33525d08d6e5fb8d27136e95: Hash của các param khi resize như ratio, transparency, quality
  • 200x200: Size của hình thumbnail
  • 351119_1.jpg: Tên file gốc

Trong các file template sẽ có 1 số đoạn như sau:

<img src="<?php echo (string) Mage::helper('catalog/image')->init($product, 'image')->resize(200, 200); ?>

Đoạn code PHP trong temlate trên sẽ làm những bước sau:

  • Xem product này đã có file thumbnail nào có size 200x200 chưa?
  • Nếu chưa có thì sẽ tạo ra 1 file thumbnail size 200x200 cho product đó tại đường dẫn thư mục "media/catalog/product/cache/1/image/200x200/9df78eab33525d08d6e5fb8d27136e95/3/5" trên disk.
    Sau đó sẽ trả về 1 URL thumbnail tương ứng "https://cdn.com/media/catalog/product/cache/1/image/200x200/9df78eab33525d08d6e5fb8d27136e95/3/5/351119_1.jpg"

Như vậy ta thấy vấn đề ở đây là: 1 page cần phải show 100 thumbnail của 100 product, thì sẽ phải query 100 lần xuống disk xem có 100 file thumbnail đó hay chưa, bất kể file thumbnail đó đã tồn tại hay chưa.
Bạn thử tưởng tượng nhiều người reload Homepage như vậy thì sẽ phải query xuống disk bao nhiêu lần???

Vậy thì mình sẽ optimize đoạn này bằng cách viết lại function "resize", bỏ bước số 1 và 2 đi, return về 1 URL thumbnail 200x200 tương ứng cho product đó.

Mà đã bỏ bước số 1 và 2 rồi thì mấy file thumbnail của product cũ có trên disk rồi thì không nói, còn mấy product mới thêm vào Database thì làm sao tạo ra file thumbnail được???

Vì Nginx đóng vai trò làm server phục vụ Image, nên mình sẽ cấu hình thêm để làm sao cho khi Browser request 1 URL thumbnail thì check xem URL này có phải là 1 file tồn tại trên disk hay chưa, nếu chưa thì sẽ "redirect" qua 1 đoạn script php tạo ra file thumbnail ứng với URL đó.

location ~ \.(gif|jpg|png) {
  try_files $uri @img_proxy;
}

location @img_proxy {
  rewrite ^(.*)$ /thumbnail.php?uri=$1;
}

Vd URL thumbnail "https://cdn.com/media/catalog/product/cache/1/image/200x200/9df78eab33525d08d6e5fb8d27136e95/3/5/351119_1.jpg" chưa tồn tại trên disk thì sẽ được redirect thành URL "https://cdn.com/thumbnail.php?uri=/media/catalog/product/cache/1/image/200x200/9df78eab33525d08d6e5fb8d27136e95/3/5/351119_1.jpg".

Session

Mặc định session sẽ được lưu vào File, nên sau này chuyển qua dùng Memcached vì sẽ đọc ghi trên RAM nên sẽ cho tốc độ truy xuất tốt hơn.

Nhưng cũng giống như File, có 1 issue gọi là "Session locking" trong PHP, mục đích để tránh nhiều request update đồng thời vào 1 session.
Vd trên Homepage mình có 5 Ajax request để lấy các product hiển thị trên 5 block khác nhau.
Mỗi ajax request phải đi qua các bước sau:

  • Start session bằng function "session_start"
  • Đọc data từ Database
  • Xử lý data
  • Return về browser

Nếu request ajax số 1 chậm ở bước 2 và 3 thì 4 request ajax còn lại sẽ bị block ngay bước 1 (start session). Khi nào request 1 xử lý xong, return về browser (lúc này session sẽ được close) thì 4 request kia mới tiếp tục xử lý.

Giải pháp là mình sẽ sử dụng function "session_write_close" để close session sớm nhất có thể, để tránh tình trạng các request khác đứng chờ lẫn nhau.

Tham khảo thêm tại đây http://konrness.com/php5/how-to-prevent-blocking-php-requests

Master-Slave MySQL

MySQL được cấu hình theo kiểu Master-Slave, 1 Master server sẽ phục vụ cho Read/Write, và 3 Slave server phục vụ Read.
3 Slave server này sẽ được load balancing thông qua Haproxy server.
Và vì không sử dụng mô hình Cluster (có thể Read/Write đồng thời trên tất cả các server) nên tuyệt đối không được write vào các Slave server (Vì Slave server sẽ không tự đồng bộ data đến Master server).

Như vậy cần phải cấu hình Magento để support Read và Write connection trên 2 IP của 2 server khác nhau.
Đối với mỗi request thì Magento sẽ tạo ra 2 Database connection: 1 cho các thao tác write vào Master server và 1 cho các thao tác Read vào Haproxy server.

Nhưng lại nảy sinh vấn đề là có 1 khoảng thời gian delay nhất định để data được đồng bộ từ Master server sang các Slave server. Vì vậy đối với những đoạn code vừa Write vào Database xong, sau đó lại Read data lên liền thì có khi sẽ không tìm thấy data đó.

Đối với những trường hợp như vậy, mình phải dùng Write connection cho các thao tác Read luôn.
Vd Magento có cách viết sau để lấy data của 1 product theo ID:

$product = Mage::getModel('catalog/product')->load(123); // Product ID = 123

Đoạn code trên mặc định dùng Read connection để lấy data từ Database.
Như vậy, mình sẽ viết thêm 1 function "setAdapter" để có thể viết như vậy:

$writeAdapter = Mage::getSingleton('core/resource')->getConnection('core_write');
$product = Mage::getModel('catalog/product')->setAdapter($writeAdapter)->load(123);

Còn viết thêm như thế nào thì cái này dễ nên mình bỏ qua nhé :)

Tối ưu các đoạn code được gọi nhiều lần

Khi cùng làm việc chung trên một source code, sẽ có trường hợp là cùng 1 function lấy data từ Database lại được sử dụng nhiều lần ở những chỗ khác trên cùng 1 trang.

Như vậy khi load 1 trang thì function đó sẽ được gọi nhiều hơn 1 lần có nghĩa là query vào Database nhiều lần.
Vd mình có function lấy 10 product mới nhất:

function get10LatestProduct()
{
  $collection = Mage::getModel('catalog/product')->getCollection()->addAttributeToSort('created_at', 'desc');
  return $collection->getSelect()->limit(10);
}

Cách giải quyết nhanh nhất là mình sẽ Cache kết quả trả về của function "get10LatestProduct",
để những lần sau gọi lại thì sẽ return về kết quả luôn, chứ không query database nữa.

function get10LatestProduct()
{
  static $collection = null;

  if ($collection === null) {
    $collection = Mage::getModel('catalog/product')
    ->getCollection()
    ->addAttributeToSort('created_at', 'desc')
    ->getSelect()
    ->limit(10);
  }

  return $collection;
}

Lời kết

Bài viết này hơi dài nên tạm thời mình chia sẻ đến đây thôi, phần 2 mình sẽ chia sẻ về Queue, Caching system và các vấn đề khác nếu mình nhớ ra nhé. Tại làm lâu quá quên bớt rồi :)
Các bạn có thắc mắc gì cứ comment bên dưới nhé. Have fun at #chuyencuadev.