Consistency between Redis Cache and SQL Database

  sonic0002        2019-07-07 08:14:16       17,818        1          English  简体中文  繁体中文  ภาษาไทย  Tiếng Việt 

Ngày nay, Redis đã trở thành một trong những giải pháp bộ nhớ đệm phổ biến nhất trong ngành Internet. Mặc dù các hệ thống cơ sở dữ liệu quan hệ (SQL) mang lại nhiều thuộc tính tuyệt vời như ACID, hiệu suất của cơ sở dữ liệu sẽ giảm khi tải cao để duy trì các thuộc tính này.

Để khắc phục vấn đề này, nhiều công ty và trang web đã quyết định thêm một lớp bộ nhớ đệm giữa lớp ứng dụng (ví dụ: mã phụ trợ xử lý logic nghiệp vụ) và lớp lưu trữ (ví dụ: cơ sở dữ liệu SQL). Lớp bộ nhớ đệm này thường được triển khai bằng bộ nhớ đệm trong bộ nhớ. Điều này là do, như đã nêu trong nhiều sách giáo khoa, nút thắt cổ chai hiệu suất của cơ sở dữ liệu SQL truyền thống thường là I/O đến bộ nhớ thứ cấp (ví dụ: ổ cứng). Vì giá bộ nhớ chính (RAM) đã giảm trong thập kỷ qua, nên giờ đây có thể lưu trữ (ít nhất một phần) dữ liệu trong bộ nhớ chính để cải thiện hiệu suất. Một lựa chọn phổ biến là Redis.

Chắc chắn, hầu hết các hệ thống sẽ chỉ lưu trữ cái gọi là “dữ liệu nóng” trong lớp bộ nhớ đệm (tức là bộ nhớ chính). Điều này là theo Nguyên tắc Pareto (còn được gọi là quy tắc 80/20), đối với nhiều sự kiện, khoảng 80% hiệu ứng đến từ 20% nguyên nhân. Để tiết kiệm chi phí, chúng ta chỉ cần lưu trữ 20% đó trong lớp bộ nhớ đệm. Để xác định “dữ liệu nóng”, chúng ta có thể chỉ định một chính sách loại bỏ (chẳng hạn như LFU hoặc LRU) để xác định dữ liệu nào sẽ hết hạn.

Bối cảnh

Như đã đề cập trước đó, một phần dữ liệu từ cơ sở dữ liệu SQL sẽ được lưu trữ trong bộ nhớ đệm trong bộ nhớ như Redis. Mặc dù hiệu suất được cải thiện, cách tiếp cận này mang lại một vấn đề lớn là chúng ta không còn một nguồn dữ liệu đáng tin cậy duy nhất nữa. Bây giờ, cùng một phần dữ liệu sẽ được lưu trữ ở hai nơi. Làm thế nào chúng ta có thể đảm bảo tính nhất quán giữa dữ liệu được lưu trữ trong Redis và dữ liệu được lưu trữ trong cơ sở dữ liệu SQL trong khi tránh bị chặn?

Dưới đây, chúng tôi trình bày một vài sai lầm phổ biến và chỉ ra những gì có thể xảy ra. Chúng tôi cũng trình bày một vài giải pháp cho vấn đề hóc búa này.

Lưu ý: để dễ dàng thảo luận ở đây, chúng tôi lấy ví dụ về Redis và cơ sở dữ liệu SQL truyền thống. Tuy nhiên, xin lưu ý rằng các giải pháp được trình bày trong bài đăng này có thể được mở rộng sang các cơ sở dữ liệu khác hoặc thậm chí là tính nhất quán giữa hai lớp bất kỳ trong hệ thống phân cấp bộ nhớ.

Các giải pháp khác nhau

Dưới đây, chúng tôi mô tả một vài cách tiếp cận vấn đề này. Hầu hết chúng đều gần như đúng (nhưng vẫn sai). Nói cách khác, chúng có thể đảm bảo tính nhất quán giữa 2 lớp trong 99,9% thời gian. Tuy nhiên, mọi thứ có thể trở nên tồi tệ (chẳng hạn như dữ liệu bẩn trong bộ nhớ đệm) khi có mức độ đồng thời rất cao và lưu lượng truy cập lớn.

Tuy nhiên, các giải pháp gần như đúng này được sử dụng rộng rãi trong ngành và nhiều công ty đã sử dụng các cách tiếp cận này trong nhiều năm mà không gặp vấn đề lớn. Đôi khi, việc chuyển từ độ chính xác 99,9% sang độ chính xác 100% là quá khó khăn. Đối với kinh doanh thực tế, vòng đời phát triển nhanh hơn và thời gian đưa sản phẩm ra thị trường ngắn hơn có lẽ quan trọng hơn.

Hết hạn bộ nhớ đệm

Một số giải pháp ngây thơ cố gắng sử dụng chính sách hết hạn hoặc giữ lại bộ nhớ đệm để xử lý tính nhất quán giữa MySQL và Redis. Mặc dù nói chung, việc đặt thời gian hết hạn và chính sách giữ lại một cách cẩn thận cho Redis Cluster của bạn là một thực hành tốt, nhưng đây là một giải pháp tồi tệ để đảm bảo tính nhất quán. Giả sử thời gian hết hạn bộ nhớ đệm của bạn là 30 phút. Bạn có chắc mình có thể chấp nhận nguy cơ đọc dữ liệu bẩn trong tối đa nửa giờ không?

Còn việc đặt thời gian hết hạn ngắn hơn thì sao? Giả sử chúng ta đặt nó là 1 phút. Thật không may, chúng ta đang nói về các dịch vụ có lưu lượng truy cập lớn và mức độ đồng thời cao ở đây. 60 giây có thể khiến chúng ta mất hàng triệu đô la.

Hmm, hãy đặt nó ngắn hơn nữa, khoảng 5 giây thì sao? Chà, bạn thực sự đã rút ngắn thời gian không nhất quán. Tuy nhiên, bạn đã đánh bại mục tiêu ban đầu của việc sử dụng bộ nhớ đệm! Bạn sẽ có rất nhiều lỗi bộ nhớ đệm và có khả năng hiệu suất của hệ thống sẽ giảm đi rất nhiều.

Cache Aside

Thuật toán cho mẫu cache aside là:

  • Đối với các hoạt động bất biến (đọc):
    • Cache hit: trả lại dữ liệu trực tiếp từ Redis, không có truy vấn nào đến MySQL;
    • Cache miss: truy vấn MySQL để lấy dữ liệu (có thể sử dụng các bản sao đọc để cải thiện hiệu suất), lưu dữ liệu trả về vào Redis, trả lại kết quả cho máy khách.
  • Đối với các hoạt động có thể thay đổi (tạo, cập nhật, xóa):
    • Tạo, cập nhật hoặc xóa dữ liệu vào MySQL;
    • Xóa mục nhập trong Redis (luôn xóa thay vì cập nhật bộ nhớ đệm, giá trị mới sẽ được chèn khi cache miss tiếp theo).

Cách tiếp cận này hầu hết sẽ hoạt động cho các trường hợp sử dụng phổ biến. Trên thực tế, cache aside là tiêu chuẩn thực tế để triển khai tính nhất quán giữa MySQL và Redis. Bài báo nổi tiếng, Scaling Memecache at Facebook cũng mô tả một cách tiếp cận như vậy. Tuy nhiên, cũng có một số vấn đề với cách tiếp cận này:

  • Trong các tình huống bình thường (giả sử quá trình không bao giờ bị hủy và việc ghi vào MySQL/Redis sẽ không bao giờ thất bại), nó hầu hết có thể đảm bảo tính nhất quán cuối cùng. Giả sử quá trình A cố gắng cập nhật một giá trị hiện có. Tại một thời điểm nhất định, A đã cập nhật thành công giá trị trong MySQL. Trước khi nó xóa mục nhập trong Redis, một quá trình khác B cố gắng đọc cùng một giá trị. B sau đó sẽ nhận được cache hit (vì mục nhập chưa bị xóa trong Redis). Do đó, B sẽ đọc giá trị đã lỗi thời. Tuy nhiên, mục nhập cũ trong Redis cuối cùng sẽ bị xóa và các quá trình khác cuối cùng sẽ nhận được giá trị đã cập nhật.
  • Trong các tình huống khắc nghiệt, nó cũng không thể đảm bảo tính nhất quán cuối cùng. Hãy xem xét cùng một kịch bản. Nếu quá trình A bị hủy trước khi nó cố gắng xóa mục nhập trong Redis, thì mục nhập cũ đó sẽ không bao giờ bị xóa. Do đó, tất cả các quá trình khác sau đó sẽ tiếp tục đọc giá trị cũ.
  • Ngay cả trong các tình huống bình thường, vẫn tồn tại một trường hợp đặc biệt với xác suất rất thấp mà tính nhất quán cuối cùng có thể bị phá vỡ. Giả sử quá trình C cố gắng đọc một giá trị và nhận được cache miss. Sau đó, C truy vấn MySQL và nhận được kết quả trả về. Đột nhiên, C bị kẹt và bị hệ điều hành tạm dừng trong một thời gian. Tại thời điểm này, một quá trình khác D cố gắng cập nhật cùng một giá trị. D cập nhật MySQL và đã xóa mục nhập trong Redis. Sau đó, C tiếp tục và lưu kết quả truy vấn của nó vào Redis. Do đó, C lưu giá trị cũ vào Redis và tất cả các quá trình tiếp theo sẽ đọc dữ liệu bẩn. Điều này nghe có vẻ đáng sợ, nhưng xác suất của nó rất thấp vì:
    • Nếu D đang cố gắng cập nhật một giá trị hiện có, thì mục nhập này theo đúng nghĩa phải tồn tại trong Redis khi C cố gắng đọc nó. Kịch bản này sẽ không xảy ra nếu C nhận được cache hit. Để trường hợp như vậy xảy ra, mục nhập đó phải hết hạn và bị xóa khỏi Redis. Tuy nhiên, nếu mục nhập này là "rất nóng" (tức là có lưu lượng đọc lớn trên đó), thì nó sẽ được lưu lại vào Redis rất sớm sau khi hết hạn. Nếu điều này thuộc về "dữ liệu lạnh", thì tính nhất quán của nó sẽ thấp và do đó rất hiếm khi có một yêu cầu đọc và một yêu cầu cập nhật trên mục nhập này đồng thời.
    • Hầu hết, việc ghi vào Redis sẽ nhanh hơn nhiều so với việc ghi vào MySQL. Trong thực tế, thao tác ghi của C trên Redis sẽ xảy ra sớm hơn nhiều so với thao tác xóa của D trên Redis.

Cache Aside - Biến thể 1

Thuật toán cho biến thể thứ 1 của mẫu cache aside là:

  • Đối với các hoạt động bất biến (đọc):
    • Cache hit: trả lại dữ liệu trực tiếp từ Redis, không có truy vấn nào đến MySQL;
    • Cache miss: truy vấn MySQL để lấy dữ liệu (có thể sử dụng các bản sao đọc để cải thiện hiệu suất), lưu dữ liệu trả về vào Redis, trả lại kết quả cho máy khách.
  • Đối với các hoạt động có thể thay đổi (tạo, cập nhật, xóa):
    • Xóa mục nhập trong Redis;
    • Tạo, cập nhật hoặc xóa dữ liệu vào MySQL.

Đây có thể là một giải pháp rất tệ. Giả sử quá trình A cố gắng cập nhật một giá trị hiện có. Tại một thời điểm nhất định, A đã xóa thành công mục nhập trong Redis. Trước khi A cập nhật giá trị trong MySQL, quá trình B cố gắng đọc cùng một giá trị và nhận được cache miss. Sau đó, B truy vấn MySQL và lưu dữ liệu trả về vào Redis. Lưu ý rằng dữ liệu trong MySQl chưa được cập nhật tại thời điểm này. Vì A sẽ không xóa lại mục nhập Redis sau này, nên giá trị cũ sẽ vẫn còn trong Redis và tất cả các lần đọc tiếp theo vào giá trị này sẽ sai.

Theo phân tích trên, giả sử các điều kiện khắc nghiệt sẽ không xảy ra, cả thuật toán cache aside gốc và biến thể 1 của nó đều không thể đảm bảo tính nhất quán cuối cùng trong một số trường hợp (chúng ta gọi các trường hợp như vậy là đường dẫn không mong muốn). Tuy nhiên, xác suất của đường dẫn không mong muốn đối với biến thể 1 cao hơn nhiều so với thuật toán gốc.

Cache Aside - Biến thể 2

Thuật toán cho biến thể thứ 2 của mẫu cache aside là:

  • Đối với các hoạt động bất biến (đọc):
    • Cache hit: trả lại dữ liệu trực tiếp từ Redis, không có truy vấn nào đến MySQL;
    • Cache miss: truy vấn MySQL để lấy dữ liệu (có thể sử dụng các bản sao đọc để cải thiện hiệu suất), lưu dữ liệu trả về vào Redis, trả lại kết quả cho máy khách.
  • Đối với các hoạt động có thể thay đổi (tạo, cập nhật, xóa):
    • Tạo, cập nhật hoặc xóa dữ liệu vào MySQL;
    • Tạo, cập nhật hoặc xóa mục nhập trong Redis.

Đây cũng là một giải pháp tồi. Giả sử có hai quá trình A và B đều cố gắng cập nhật một giá trị hiện có. A cập nhật MySQL trước B; tuy nhiên, B cập nhật mục nhập Redis trước A. Cuối cùng, giá trị trong MySQL được cập nhật bởi B; tuy nhiên, giá trị trong Redis được cập nhật bởi A. Điều này sẽ gây ra sự không nhất quán.

Tương tự, xác suất của đường dẫn không mong muốn đối với biến thể 2 cao hơn nhiều so với cách tiếp cận ban đầu.

Read Through

Thuật toán cho mẫu read through là:

  • Đối với các hoạt động bất biến (đọc):
    • Máy khách sẽ luôn chỉ đọc từ bộ nhớ đệm. Cache hit hoặc cache miss đều trong suốt đối với máy khách. Nếu là cache miss, bộ nhớ đệm phải có khả năng tự động tìm nạp từ cơ sở dữ liệu.
  • Đối với các hoạt động có thể thay đổi (tạo, cập nhật, xóa):
    • Chiến lược này không xử lý các hoạt động có thể thay đổi. Nó nên được kết hợp với mẫu write through (hoặc write behind).

Một nhược điểm chính của mẫu read through là nhiều lớp bộ nhớ đệm có thể không hỗ trợ nó. Ví dụ: Redis sẽ không thể tự động tìm nạp từ MySQL (trừ khi bạn viết một plugin cho Redis).

Write Through

Thuật toán cho mẫu write through là:

  • Đối với các hoạt động bất biến (đọc):
    • Chiến lược này không xử lý các hoạt động bất biến. Nó nên được kết hợp với mẫu read through.
  • Đối với các hoạt động có thể thay đổi (tạo, cập nhật, xóa):
    • Máy khách chỉ cần tạo, cập nhật hoặc xóa mục nhập trong Redis. Lớp bộ nhớ đệm phải đồng bộ hóa nguyên tử thay đổi này với MySQL.

Những nhược điểm của mẫu write through cũng rất rõ ràng. Đầu tiên, nhiều lớp bộ nhớ đệm sẽ không hỗ trợ điều này một cách tự nhiên. Thứ hai, Redis là một bộ nhớ đệm chứ không phải là RDBMS. Nó không được thiết kế để có khả năng phục hồi. Do đó, các thay đổi có thể bị mất trước khi chúng được sao chép sang MySQL. Ngay cả khi Redis hiện đã hỗ trợ các kỹ thuật duy trì như RDB và AOF, thì cách tiếp cận này vẫn không được khuyến nghị.

Write Behind

Thuật toán cho mẫu write behind là:

  • Đối với các hoạt động bất biến (đọc):
    • Chiến lược này không xử lý các hoạt động bất biến. Nó nên được kết hợp với mẫu read through.
  • Đối với các hoạt động có thể thay đổi (tạo, cập nhật, xóa):
    • Máy khách chỉ cần tạo, cập nhật hoặc xóa mục nhập trong Redis. Lớp bộ nhớ đệm lưu thay đổi vào hàng đợi tin nhắn và trả lại thành công cho máy khách. Thay đổi được sao chép vào MySQL không đồng bộ và có thể xảy ra sau khi Redis gửi phản hồi thành công cho máy khách.

Mẫu write behind khác với write through vì nó sao chép các thay đổi vào MySQL không đồng bộ. Nó cải thiện thông lượng vì máy khách không phải đợi quá trình sao chép xảy ra. Một hàng đợi tin nhắn có độ bền cao có thể là một triển khai khả thi. Redis stream (được hỗ trợ từ Redis 5.0) có thể là một lựa chọn tốt. Để cải thiện hơn nữa hiệu suất, có thể kết hợp các thay đổi và cập nhật MySQL theo lô (để tiết kiệm số lượng truy vấn).

Những nhược điểm của mẫu write behind cũng tương tự. Đầu tiên, nhiều lớp bộ nhớ đệm không hỗ trợ điều này một cách tự nhiên. Thứ hai, hàng đợi tin nhắn được sử dụng phải là FIFO (vào trước ra trước). Nếu không, các bản cập nhật cho MySQL có thể không theo thứ tự và do đó kết quả cuối cùng có thể không chính xác.

Double Delete

Thuật toán cho mẫu double delete là:

  • Đối với các hoạt động bất biến (đọc):
    • Cache hit: trả lại dữ liệu trực tiếp từ Redis, không có truy vấn nào đến MySQL;
    • Cache miss: truy vấn MySQL để lấy dữ liệu (có thể sử dụng các bản sao đọc để cải thiện hiệu suất), lưu dữ liệu trả về vào Redis, trả lại kết quả cho máy khách.
  • Đối với các hoạt động có thể thay đổi (tạo, cập nhật, xóa):
    • Xóa mục nhập trong Redis;
    • Tạo, cập nhật hoặc xóa dữ liệu vào MySQL;
    • Ngủ một lúc (chẳng hạn như 500ms);
    • Xóa lại mục nhập trong Redis.

Cách tiếp cận này kết hợp thuật toán cache aside gốc và biến thể thứ 1 của nó. Vì nó là một cải tiến dựa trên cách tiếp cận cache aside gốc, chúng ta có thể tuyên bố rằng nó hầu hết đảm bảo tính nhất quán cuối cùng trong các tình huống bình thường. Nó đã cố gắng khắc phục đường dẫn không mong muốn của cả hai cách tiếp cận.

Bằng cách tạm dừng quá trình trong 500ms, thuật toán giả định rằng tất cả các quá trình đọc đồng thời đã lưu giá trị cũ vào Redis và do đó thao tác xóa thứ 2 trên Redis sẽ xóa tất cả dữ liệu bẩn. Mặc dù vẫn tồn tại một trường hợp đặc biệt mà thuật toán này phá vỡ tính nhất quán cuối cùng, nhưng xác suất của điều đó sẽ không đáng kể.

Write Behind - Biến thể

Cuối cùng, chúng tôi trình bày một cách tiếp cận mới được giới thiệu bởi dự án canal do Alibaba Group từ Trung Quốc phát triển.

Phương pháp mới này có thể được coi là một biến thể của thuật toán write behind. Tuy nhiên, nó thực hiện sao chép theo hướng khác. Thay vì sao chép các thay đổi từ Redis sang MySQL, nó đăng ký vào binlog của MySQL và sao chép nó sang Redis. Điều này cung cấp độ bền và tính nhất quán tốt hơn nhiều so với thuật toán gốc. Vì binlog là một phần của công nghệ RDMS, chúng ta có thể giả định rằng nó có độ bền và khả năng phục hồi khi gặp sự cố. Một kiến trúc như vậy cũng khá trưởng thành vì nó đã được sử dụng để sao chép các thay đổi giữa máy chủ chính và máy chủ phụ của MySQL.

Kết luận

Tóm lại, không có cách tiếp cận nào ở trên có thể đảm bảo tính nhất quán mạnh mẽ. Tính nhất quán mạnh mẽ cũng có thể không phải là một yêu cầu thực tế đối với tính nhất quán giữa Redis và MySQL. Để đảm bảo tính nhất quán mạnh mẽ, chúng ta phải triển khai ACID trên tất cả các hoạt động. Làm như vậy sẽ làm giảm hiệu suất của lớp bộ nhớ đệm, điều này sẽ đánh bại các mục tiêu của chúng ta khi sử dụng bộ nhớ đệm Redis.

Tuy nhiên, tất cả các cách tiếp cận trên đã cố gắng đạt được tính nhất quán cuối cùng, trong đó cách tiếp cận cuối cùng (được giới thiệu bởi canal) là tốt nhất. Một số thuật toán trên là cải tiến của một số thuật toán khác. Để mô tả hệ thống phân cấp của chúng, sơ đồ cây sau được vẽ. Trong sơ đồ, mỗi nút nói chung sẽ đạt được tính nhất quán tốt hơn so với các nút con của nó (nếu có).

Chúng tôi kết luận rằng sẽ luôn có sự đánh đổi giữa độ chính xác 100% và hiệu suất. Đôi khi, độ chính xác 99,9% đã đủ cho các trường hợp sử dụng trong thế giới thực. Trong các nghiên cứu trong tương lai, chúng tôi nhắc nhở rằng mọi người nên nhớ không đánh bại các mục tiêu ban đầu của chủ đề. Ví dụ: chúng ta không thể hy sinh hiệu suất khi thảo luận về tính nhất quán giữa MySQL và Redis.

Tài liệu tham khảo

Lưu ý bài đăng này được phép đăng lại ở đây bởi tác giả gốc Yunpeng Niu, một sinh viên CS xuất sắc của NUS. Để đọc thêm các bài đăng của anh ấy, vui lòng truy cập https://yunpengn.github.io/blog/

DATABASE  CACHE  REDIS 

       

  RELATED


  1 COMMENT


Aviv Kandabi [Reply]@ 2021-02-23 02:55:59

Great article! really helped me out understanding a correct workflow :)



  RANDOM FUN

The correct way to handle JavaScript exception