Quần Cam

Một số kĩ thuật caching với HTTP/1.1

image cover

“Make it work, make it right, make it fast.”

Bạn vừa viết xong một ứng dụng web . Mọi thứ chạy ổn. Code cũng đã được tổ chức thật đẹp. Để thuyết phục sếp tiếp tục trả lương cho bạn, bạn cần liên tục vẽ thêm việc để làm tối ưu cho ứng dụng chạy nhanh hơn .

Như bạn đã biết, ứng dụng web dùng HTTP , là một giao thức phân tán. Khi người dùng cần truy cập resource từ một remote server, HTTP/1.0 mở kết nối HTTP đến remote server đó, gửi request, nhận response, đóng kết nối. HTTP/1.1 và HTTP/2 thì tốt hơn, cho phép bạn dùng lại kết nối cũ. Dù sao chăng nữa, dữ liệu đều phải di chuyển một round-trip cả đi lẫn về để hoàn thành một HTTP request.

Ví dụ như blog Quần Cam. Khi người dùng gõ đường dẫn https://quancam.net vào thanh địa chỉ và nhấn Enter, browser của bạn phải gửi request đi một khoảng cách 10,000 km đến Heroku. Response sau khi được sinh ra cũng phải di chuyển một quãng đường tương tự để quay về browser của bạn.

Trong điều kiện hoàn mĩ nhất, với dữ liệu di chuyển bằng tốc độ ánh sáng và dây cáp được viết bằng blockchain, round-trip này sẽ mất 66.666ms để hoàn thành (20,000 km % 300,000 km/s). Chưa kể các trường hợp retry do mất packet hay network không ổn định.

Làm thế nào để Quần Cam có thể phục vụ người dùng nhanh hơn , mà không phải dời server về gần Việt Nam?

Giao thức Quần Cam

Chắc bạn cũng đoán được câu trả lời là caching. Tiếp tục vô chiêu thắng hữu chiêu. Một chương trình máy tính sẽ chạy nhanh nhất khi nó không chạy gì cả. Tương tự, một HTTP request sẽ nhanh nhất nếu như nó không được tạo ra.

Với mỗi HTTP request, chúng ta sẽ lưu response của nó thành một chỉ mục cache trên browser. Ở những lần sau, ta sẽ dùng lại những chỉ mục cache này, thay vì tạo gửi request mới lên server.

Caching là một giải pháp win-win. Người dùng không cần phải tải lại resource đã tải trước đó. Còn server thì không cần phải tốn tài nguyên hệ thống để phục vụ những request lặp đi lặp lại. Ứng dụng được tăng tốc. Người dùng vui làm boss của bạn cũng vui lây. Còn chần chừ gì nữa mà không đòi tăng lương???

Tuy nhiên để caching hoạt động hiệu quả, server và client phải bắt tay định ra một giao thức . Chủ yếu là trả lời cho hai câu hỏi:

  1. Cache thế nào? - resource nào được cache và response nào không?
  2. Bản cache còn hợp lệ không? - để đảm bảo bản cache của bạn luôn được cập nhật.

Để trả lời câu hỏi số 1, server gắn giá trị yes/no vào can-browser-cache header của từng resource. Nhờ đó browser biết nó nên cache /assets/main.js và không nên cache /api/usd-vnd-exchange-rate.json .

Bên cạnh browser, ta còn phải quan tâm đến một vài thứ khác. Một số user thích sử dụng proxy hoặc VPN để tránh kiểm duyệt cho vui. Một số server thì sử dụng các dịch vụ proxy như nginx hay Varnish để giảm tải cho server chính. Các thành phần trung gian này cũng có thể dùng cache để tăng tốc. Để hỗ trợ, server cần cung cấp thêm một can-proxy-cache header.

Đọc tới đây chắc bạn sẽ thắc mắc: Vì sao không để proxy dùng luôn can-browser-cache cho tiện?

Lý do là những proxy này thường được dùng chung bởi nhiều người. Với những resource mang tính riêng tư như tài khoản người dùng hay trang thanh toán, tốt hơn hết là những proxy không nên cache (nhưng browser thì có khi). Trừ phi bạn muốn tạo ra thảm họa mang tính hủy diệt. Tưởng tượng một buổi sáng đẹp trời bạn mở trang https://facelock.com/users/me và thấy profile của Donald Trump hơn là của bạn, vì bạn và ổng dùng chung một proxy.

Để trả lời cho câu hỏi số 2, server sẽ trả về thêm refresh-at header. Header này chứa thời điểm resource cần được cập nhật, được dùng để điều khiển thời hạn hợp lệ của chỉ mục cache.

Sau những gì đã bàn nãy giờ, một HTTP request dùng “giao thức Quần Cam” sẽ có hình dạng như sau:

quan-cam-protocol

Giao thức trên còn một điểm chưa tối ưu . Sau khi chỉ mục cache hết hạn, resource luôn được tải lại dù cho nó có thay đổi hay không. Ta có thể làm tốt hơn bằng cách cho server sẽ trả thêm một header checksum chứa MD5 fingerprint của response body.

Sau đó browser sẽ đính kèm checksum vào header của mỗi request cho resource tương tự. Server dùng nó để kiểm tra resource đã được thay đổi hay chưa, sau đó trả về 304 Not Modified hoặc 200 OK tùy vào trạng thái của resource.

# server.rb
def show
  post = Post.find_by(slug: params["slug"])
  body = JSON.dump(post)
  checksum = Digest::MD5.digest(body)
  request_checksum = @request.headers.get("checksum")

  if request_checksum == checksum
    @response.send(status: 304, headers: {"checksum" => checksum}, body: nil)
  else
    @response.send(status: 200, headers: {"checksum" => checksum}, body: body)
  end
end

image-02

Như vậy, giao thức này đòi hỏi sự hợp tác từ cả ba phía (server, proxy, client) để caching được thực hiện suôn sẻ. Server sẽ đưa ra caching policy cho từng resource (như ai được cache, cache bao lâu), thông qua việc thao túng các header. Proxy và client sẽ dựa vào những header này để tái sử dụng và cập nhật các chỉ mục cache một cách chính xác.

HTTP/1.1 Caching

Phần trên là giao thức đơn giản nhất để bắt đầu thi triển caching cho một ứng dụng dùng HTTP. Trong thực tiễn thì chúng ta không cần phát minh ra một giao thức caching nào cả, vì nó đã đi kèm với HTTP/1.1 trong RFC7234 rồi.

Trọng tâm của giao thức này nằm ở việc thao túng các directive của cache-control header và một số header liên quan như last-modifiedetag để điều khiển caching.

Chú ý là mặc dù HTTP Caching thường được áp dụng cho GET requests, nhưng nó cũng có thể được dùng cho những HTTP method khác, như 404 requests, hoặc 301 redirect. Cache key của một chỉ mục cache là tuple {METHOD, URI}.

Điều khiển cache

cache-control header giúp server định nghĩa chính sách cache cho từng resource. cache-control có khá nhiều directives, nhưng tui thấy mấy cái sau đây là quan trọng nhất:

  • private/public - public cho phép người người nhà nhà được cache lại resource, bao gồm cả proxy. Còn private cho biết resource chỉ dành cho browser của người dùng cuối.
  • no-cache - khi có directive này, luôn luôn kiểm tra lại với server trước khi tái sử dụng chỉ mục cache.
  • no-store - cấm lưu và yêu cầu luôn luôn down lại mỗi khi có nhu cầu đọc resource. Directive này hữu ích cho các resource cần cập nhật liên tục như tỉ giá USD-VND hoặc kèo cá độ bóng đá , hay thông tin nhạy cảm như trang cập nhật mật khẩu.
  • max-age - điều khiển tuổi thọ tối đa của một chỉ mục cache. Có thể tạm hiểu là thời gian tối đa resource được tồn tại trong cache. Tui sẽ giải thích kỹ hơn ở phần tính tuổi bên dưới.

cache-control trong ví dụ sau cho phép caching ở mọi nơi, tuổi thọ tối đa của chỉ mục cache là 1 ngày, nhưng phải luôn kiểm tra lại với server trước khi tái sử dụng nó.

GET /posts/self-help

HTTP/1.1 200 OK
date: Mon, 27 Jul 2009 12:28:53 GMT
cache-control: public,no-cache,max-age=86400

Vô hiệu hóa cache

Người ta nói có hai thứ khó trong lập trình: đặt tên biến và vô hiệu hóa cache. Ta hãy xem HTTP/1.1 giải bài toán khó này như thế nào.

Để kiểm tra và vô hiệu hóa cache, ta cần thực hiện ở hai nơi: trên local và trên server.

Trên local

Như đã biết, max-age directive cho bạn biết tuổi thọ tối đa của một resource. Vậy trước hết ta phải tính tuổi thọ của một chỉ mục cache.

Công thức tính tuổi là một giải thuật “bảo thủ” và rối rắm, nhưng có thể được hiểu như sau:

Với mỗi resource, server sẽ gửi kèm date header, chỉ thời gian nó được sinh ra ở server. Để suy ra tuổi thọ của resource, ta chỉ cần tính số giây từ giá trị của date tới thời điểm hiện tại. Ví dụ như HTTP response bên dưới, vào lúc nhận được resource là 12:28:55, tuổi thọ khởi tạo của chỉ mục cache là 2.

GET /posts/self-helf

HTTP/1.1 200 OK
date: Mon, 27 Jul 2009 12:28:53 GMT

Trong trường hợp resource được trả về từ cache của một proxy khác, response sẽ kèm thêm header age. Đây là tuổi thọ của resource mà proxy đã ướm chừng trước khi trả về.

GET /posts/self-helf

HTTP/1.1 200 OK
date: Mon, 27 Jul 2009 12:28:53 GMT
age: 20

Lúc này, ta không rõ tuổi thọ được được tính khi nào trong khoảng thời gian từ lúc gửi request tới lúc nhận được response. Vì vậy, để cho chắc, ta cộng tuổi thọ mà proxy trả về với X là tổng hao tốn của round-trip request-response. Với ví dụ ở trên, ta tính được tuổi thọ khởi tạo của chỉ mục cache là 20 + X. Đó là lý do giải thuật này được gọi là “bảo thủ”.

Mỗi lần chỉ mục cache được sử dụng, browser sẽ tính lại tuổi thọ hiện thời, bằng cách lấy tuổi thọ khởi tạo cộng với thời gian trôi đi. Sau đó so sánh nó với max-age để kiểm tra chỉ mục cache đã đáo hạn hay chưa.

Trên server

Trong trường hợp chỉ mục cache trên local đáo hạn hoặc cache-control của nó chứa no-cache, ta phải kiểm tra nó với server.

Giao thức HTTP/1.1 định nghĩa hai phương thức để validate chỉ mục cache với server: dùng last-modified hoặc etag trong HTTP response.

last-modifiedthời điểm chỉnh sửa cuối cùng của resource. Khi gửi request, ta sẽ gởi kèm giá trị này vào if-modified-since header. Server có thể thực hiện kiểm tra với đoạn mã giả như sau:

class PostsController
  def show
    post = Post.find_by(slug: params["slug"])

    if post
      if_modified_since = DateTime.from_rfc1123(headers["if-modified-since"])
      response_headers = {"last-modified" => DateTime.to_rfc1123(post.updated_at)}

      if if_modified_since && post.updated_at <= if_modified_since
        send_response(status: 304, body: nil, headers: response_headers)
      else
        send_response(
          status: 200,
          body: View.render("posts/show", post: post),
          headers: response_headers
        )
      end
    else
      send_response(status: 404, body: "not found", headers: [])
    end
  end
end

etag được xem như là identifier (định danh) cho một version của resource. Khi gửi request, client sẽ kèm giá trị ETag vào if-none-match header. Mã giả để validate trên server có thể viết như sau:

class PostsController
  def show
    post = Post.find_by(slug: params["slug"])

    if post
      body = View.render("posts/show", post: post)
      none = DateTime.from_rfc1123(headers["if-none-match"])
      etag = MD5.hash(body)
      response_headers = {"etag" => etag}

      if etag == none
        send_response(status: 200, body: body, headers: response_headers)
      else
        send_response(status: 304, body: nil, headers: response_headers)
      end
    else
      send_response(status: 404, body: "not found", headers: [])
    end
  end
end

Weak và Strong validator

HTTP/1.1 còn có khái niệm validator mạnh và yếu. Strong validator đảm bảo cache entry được vô hiệu hóa khi resource thay đổi. Weak validator thì yếu hơn, mặc dù vẫn đảm bảo tính tương đương về mặt ngữ nghĩa, nhưng chỉ chứa một phần của identifier. Vì vậy strong validator còn được gọi là bit-to-bit validator .

Để cho dễ hiểu, ta cùng xem hai thuật toán tính etag sau đây:

def strong_etag(payload) do
  etag = payload |> JSON.encode!() |> md5()
  "\"" <> etag <> "\""
end

def weak_etag(payload) do
  etag = payload |> Map.delete("views") |> JSON.encode!() |> md5()
  "W/\"" <> etag <> "\""
end

payload = %{
  "id" => 42,
  "updated_at" => "2018-09-09T20:00:00Z",
  "title" => "HTTP Caching",
  "views" => 1024
}

strong_etag(payload)
# => "438cfc06ba78943339bd26372d386b75"
weak_etag(payload)
# => W/"b92b087eddfaeef8219de5429a7a0772"

Ở thuật toán weak_etag, giá trị của ETag không đổi dù views thay đổi. Nhờ vậy chỉ mục cache được sử dụng lâu hơn ở client. Weak validator được dùng khi một vài thuộc tính trong resource được phép lỗi thời. Weak và strong ETags được phân biệt thông qua ký hiệu W/ đại diện cho weak.

last-modified cũng được xem là một weak validator, bởi ngày tháng theo định dạng RFC-1123 làm tròn đến đơn vị giây. Trên lý thuyết một resource có thể được update nhiều lần trong vòng một giây.

Kĩ thuật Revving

Ngoài các kĩ thuật caching được hướng dẫn trong giao thức HTTP/1.1, dân chơi web còn sử dụng một kĩ thuật gọi là revving.

Sau khi nghe Quần Cam chém gió, bạn về gắn cache-control cho /assets/logo.png trên trang web công ty thành public,max-age=86400000, tương đương với 1000 ngày. Thế là số lượng request cho logo giảm hẳn, tốc độ load cho website lên cao. Bạn vui không kể xiết. Tiệc tùng linh đình ba ngày ba đêm.

Đùng một cái, 2 tháng sau, bộ phận branding của công ty yêu cầu bạn thay logo. Tuy nhiên vì bạn lỡ mắm dố gắn max-age quá nhiều, bạn đành chờ cho cache trên browser của người dùng hết hạn, hoặc người dùng hard reset.

Revving là một kĩ thuật để giúp bạn không bị rơi vào tình trạng éo le đó, bằng cách gắn fingerprint/version vào tên file, như logo-438cfc.png, và gắn cho nó một max-age tương đương Tết Công-gô. Khi logo thay đổi, điều bạn cần làm chỉ là đổi fingerprint của file. Vì HTTP caching phân biệt resource qua URI, khi bạn đổi tên file, browser sẽ tự load file mới.

Kĩ thuật này còn một ưu điểm khác là giúp ta tránh được tình trạng “râu ông nọ cắm cằm bà kia” giữa các resource. Giả dụ ứng dụng PhoneHub có file main.cssmain.js phụ thuộc vào nhau. Một ngày, cô developer mới vào tên Quần Đùi quyết định trổ tài refactor với bản patch sau:

diff --git a/src/main.js b/src/main.js
--- a/src/main.js
+++ b/src/main.js
- document.getElementById("ads-banner").classList.add("disappear");
+ document.getElementById("ads-banner").classList.add("hidden");

diff --git a/src/main.css b/src/main.css
--- a/src/main.css
+++ b/src/main.css
- .disappear { display: none; }
+ .hidden { display: none; }
<!-- index.html -->
<script src="/assets/main.js"></script>
<link rel="stylesheet" href="/assets/main.css" />
<div id="ads-banner">...</div>

Sau khi refactor thành công và tung ra thị trường main.cssmain.js mới, bộ phận chăm sóc khách hàng của PhoneHub bắt đầu nhận được những tin nhắn phẫn nộ từ người dùng. Nhìn chung là phàn nàn họ đã trả tiền subscription nhưng quảng cáo vẫn hiện khi xem phone. Ừa xem phone mà có quảng cáo thì đúng là bực bội thiệt.

Thế là incident level, các developer của PhoneHub nhảy vào tìm hiểu nhưng hoàn toàn không hiểu lỗi nằm ở đâu. Được một hồi thì nhận ra bug nằm ở browser cache. Khi browser X dùng version cũ của main.css và version mới của main.js hoặc ngược lại, lỗi banner quảng cáo sẽ xảy ra. Bực cả mình, cả team quyết định bắt Quần Đùi xem phone có quảng cáo một tháng.

Revving có thể được sử dụng để tránh vấn đề trên. Bằng cách gắn version vào tên file như main-abc.js, main-123.css, ta có thể yên tâm release version mới mà không lo resource nhảy lung tung xà beng.

Bài viết này sẽ giúp tôi tăng lương như thế nào?

Như thường lệ, bài viết này không giúp bạn tăng lương. Tui mong là bài viết đã giúp bạn nắm được cách hiện thực caching theo chuẩn của HTTP/1.1.

Theo như HTTP Archive, browser có thể cache gần một nửa các resource được download. Thậm chí một số trang, như Blog Quần Cam chẳng hạn, có thể cache được đến 80-90% resources. Áp dụng tốt chuẩn caching này có thể giúp bạn tăng tốc đáng kể ứng dụng web mà không cần phải tốn quá nhiều sức.

Nếu bạn làm việc phía backend và API, bạn có thể tăng tốc bằng cách cấu hình lại cache-control cho từng resource sao cho hợp lý.

Nếu bạn dùng các proxy như Nginx hoặc Varnish, chúng đều đã tương thích với HTTP/1.1 Caching. Các CDN như Cloudflare, Fastly, Cloudfront, chúng cũng hoạt động dựa trên chuẩn này.

Nếu bạn làm việc với front-end và browser, giao thức này cũng được hỗ trợ tận răng. Ngoài ra việc ứng dụng thêm kĩ thuật revving có thể giúp giảm thiểu sự phụ thuộc lẫn nhau của các resource.

Hỡi thanh niên, ngại gì vết bẩn mà không bắt đầu tối ưu hóa các response header đi?


NGUY HIỂM! KHU VỰC NHIỀU GIÓ!
Khuyến cáo giữ chặt bàn phím và lướt thật nhanh khi đi qua khu vực này.
Chức năng này hỗ trợ markdown và các thứ liên quan.

Bài viết cùng chủ đề

Elixir - Ngôn ngữ được viết bằng macros

Macro không những là một trong những viên gạch giúp bạn meta-program với Elixir, mà nó còn là công cụ giúp Elixir meta-program … chính nó nữa.

IO data và Vectored IO

Bài viết giới thiệu về IO data, Vectored I/O và tối ưu hóa hệ thống dùng Elixir bằng cách tận dụng Vectored I/O.

do {} while(0) loop