Note: see the link below for the English version of this article.

https://duongnt.com/redis-raw-bytes

Send raw bytes to Redis with RedisTemplate

Thông thường, khi sử dụng RedisTemplate để gửi dữ liệu tới Redis, nó sẽ chuyển dữ liệu đó thành dạng string với encoding UTF-8 trước khi thực hiện serialize. Giải pháp này là đủ tốt trong phần lớn các trường hợp. Nhưng đôi khi, ta có thể tiết kiệm được một phần bộ nhớ bằng cách gửi trực tiếp dữ liệu dưới dạng byte tới Redis. Trong bài hôm nay, ta sẽ thử nghiệm phương án này để xem nó có những ưu và nhược điểm gì.

Các bạn có thể tải code ví dụ trong bài từ đường link dưới đây.

https://github.com/duongntbk/redis-raw-bytes-demo

Điều kiện tiên quyết

Ta cần một server Redis để chạy code trong bài hôm nay, cách đơn giản nhất là sử dụng Docker. Dưới đây là lệnh để tạo và chạy một server Redis bằng Docker.

docker run -d --name redis -p 6379:6379 redis:latest redis-server --requirepass <password của bạn>

Vấn đề ta muốn giải quyết

Giả sử ta cần thực hiện cache bằng Redis với những yêu cầu dưới đây.

  • Key là object Long với từ 10 ~ 11 chữ số.
  • Value là object Double với 17 chữ số sau dấu phẩy (tối đa cho phép của kiểu dữ liệu Double).
  • Cần hỗ trợ pipeline để ta có thể gộp nhiều lệnh và gửi chung trong một connection.
  • Không bắt ta phải tự thực hiện serialize/deserialize mỗi khi tương tác với Redis.

Dùng RedisTemplate với serializer có sẵn

Thiết lập bean với kiểu RedisTemplate

Đây là cách ta thiết lập bean với kiểu RedisTemplate<Long, Double>. Ta sử dụng serializer sẵn có với tên gọi GenericToStringSerializer.

redisTemplate.keySerializer = GenericToStringSerializer(Long::class.java)
redisTemplate.valueSerializer = GenericToStringSerializer(Double::class.java)

Class GenericToStringSerializer sẽ chuyển cả key và value sang dạng string trước khi thực hiện serialize.

Sau đó, ta có thể lấy object RedisTemplate từ app context.

val redisTemplateSerialize = context.getBean(
    "redisTemplateSerialize", RedisTemplate::class.java
) as RedisTemplate<Long, Double>

Kích cỡ của một entry khi sử dụng serializer sẵn có

Dưới đây là code để gửi 2 giá trị lên Redis.

redisTemplate.opsForValue().multiSet(
	mapOf(
		6359284517L to 0.5238106733071787,
		Long.MAX_VALUE to 0.6238106733071787,
	)

Ta có thể kiểm tra là chúng đã được ghi vào Redis. Cả key lẫn value của chúng đều đã được chuyển thành dạng String.

127.0.0.1:6379> get "6359284517"
"0.5238106733071787"
127.0.0.1:6379> get "9223372036854775807"
"0.6238106733071787"

Tiếp theo, hãy thử xem chúng chiếm bao nhiêu bộ nhớ.

127.0.0.1:6379> memory usage "6359284517"
(integer) 80
127.0.0.1:6379> memory usage "9223372036854775807"
(integer) 88
127.0.0.1:6379>

Như ta thấy, kích cỡ của 9223372036854775807 lớn hơn kích cỡ của 6359284517. Lý do vì giá trị đầu tiên có nhiều chữ số hơn, vì thế khi chuyển sang dạng string nó sẽ chiếm nhiều bộ nhớ hơn giá trị Long ban đầu. Tương tự thế, vì phần value cũng có nhiều chữ số nên chúng chiếm nhiều bộ nhớ hơn giá trị một object kiểu Double.

Dùng serializer tự viết để gửi trực tiếp byte tới Redis

Thiết lập RedisTemplate với serializer tự viết

Ta cần thay thế GenericToStringSerializer để ngăn không cho RedisTemplate chuyển dữ liệu của ta sang dạng string. Mặc dù Spring Data Redis không hỗ trợ sẵn serializer Long <-> ByteArray and Double <-> ByteArray, nhưng ta có thể tự viết chúng bằng cách implement interface RedisSerializer<T>. Các bạn có thể tham khảo một phiên bản mẫu ở trong thư mục serializer của code demo.

Sau đó, ta dùng serializer này khi thiết lập bean như đây.

redisTemplate.keySerializer = LongToByteArraySerializer()
redisTemplate.valueSerializer = DoubleToByteArraySerializer()

Và ta có thể lấy ra bean đó từ app context.

val redisTemplateByteArray = context.getBean(
    "redisTemplateByteArray", RedisTemplate::class.java
) as RedisTemplate<Long, Double>

Kích cỡ của entry khi dùng trực tiếp byte

Code sử dụng redisTemplateByteArray không khác gì phiên bản ở phần trước. Ta sẽ kiểm tra các entry trong Redis.

127.0.0.1:6379> keys *
1) "%\xfb\n{\x01\x00\x00\x00"
2) "\xff\xff\xff\xff\xff\xff\xff\x7f"
127.0.0.1:6379> get "%\xfb\n{\x01\x00\x00\x00"
"?\xe0\xc3\x0e\x99\xe4\xcde"
127.0.0.1:6379> get "\xff\xff\xff\xff\xff\xff\xff\x7f"
"?\xe3\xf6A\xcd\x18\x00\x98"

Tất cả các giá trị key và value đều đã sử dụng trực tiếp byte. Giờ đây ta sẽ xem kích thước của chúng.

127.0.0.1:6379> memory usage "%\xfb\n{\x01\x00\x00\x00"
(integer) 72
127.0.0.1:6379> memory usage "\xff\xff\xff\xff\xff\xff\xff\x7f"
(integer) 72

Có thể thấy rằng kích thước của cả 2 entry giờ đây đều chỉ là 72 byte. Ta đã giảm tiêu hao bộ nhớ được 10% trong trường hợp bình thường, và giảm được 18% trong trường hợp xấu nhất (key có nhiều chữ số nhất có thể).

Dùng connection để gửi byte lên Redis

Class RedisTemplate cho phép ta encapsulate quá trình serialize dữ liệu, và ta nên sử dụng nó trong phần lớn các trường hợp. Tuy nhiên, nó không trực tiếp hỗ trợ gộp nhiều lệnh trong một connection. Để làm điều đó, ta cần sử dụng trực tiếp LettuceConnection.

Các bước sẽ như sau.

  • Tạo một bean với kiểu là RedisConnectionFactory.
  • Lấy factory từ AppContext.
  • Dùng factory để tạo connection, dùng connection để tạo pipeline, gửi dữ liệu qua pipeline, rồi đóng pipeline (code).

Có thể thấy là ta phải tự thực hiện việc chuyển từ Long/Double sang ByteArray. Nhưng ích lợi của việc gộp nhiều lệnh trong một connection có thể là đủ để ta làm điều này.

Nhược điểm của việc trực tiếp gửi byte

Tỷ lệ bộ nhớ ta tiết kiệm được chỉ là 10 ~ 18%, thấp hơn ta mong đợi. Thoạt nhìn qua, string “6359284517” có kích cỡ 10 byte, còn string “0.5238106733071787” là 18 bytes. Tức là nếu tính một cách đơn giản, kích cỡ của entry khi dữ liệu bị chuyển thành string là 28 byte, trong khi một cặp LongDouble chỉ chiếm 16 byte. Tuy nhiên, Redis cũng cần sử dụng bộ nhớ để lưu kiến trúc dùng để chứa entry. Và nó cũng phải lưu cả thời gian entry bị hết hạn (nếu có).

Một nhược điểm nữa của việc gửi trực tiếp byte là Redis có thể sẽ tiêu tốn nhiều bộ nhớ hơn nếu con số ta dùng là ngắn (ít chữ số). Đó là vì kích cỡ tối thiểu của một giá trị String với encoding UTF-8 là 1 byte, trong khi Long hay Byte luôn chiếm đúng 8 byte.

Dưới đây là kích thước của entry { 1: 1.0 }.

127.0.0.1:6379> memory usage "1"
(integer) 56
127.0.0.1:6379> memory usage "\x01\x00\x00\x00\x00\x00\x00\x00"
(integer) 72

Ta không những không tiết kiệm được chút bộ nhớ nào mà còn tốn thêm 30% bộ nhớ cho mỗi entry. Nếu ta không chắc chắn rằng cả key lẫn value của ta đều có nhiều chữ số thì ta nên sử dụng serializer mặc định.

Có thể dùng serializer pass-through hay không?

Ta cũng có thể sử dụng một serializer pass-through (không thay đổi gì dữ liệu) và tự thực hiện việc chuyển đổi dữ liệu sang byte. Lúc này, cách để thiết lập bean sẽ như dưới đây (chú ý là template giờ có kiểu là RedisTemplate<ByteArray, ByteArray>)

fun redisTemplate(): RedisTemplate<ByteArray, ByteArray> {
    //...

    redisTemplate.keySerializer = RedisSerializer.byteArray()
    redisTemplate.valueSerializer = RedisSerializer.byteArray()
    //...
}

Và đây là cách ta dùng nó.

val key = // code chuyển 6359284517L thành ByteArray
val value = // code chuyển Long.MAX_VALUE thành ByteArray
redisTemplate.opsForValue().set(key, value)

Về mặt chức năng thì phương án này không khác gì cách dùng serializer tự viết ở trên. Nhưng tôi không thấy có lý do gì để ta sử dụng phương án này. Bởi vì nó khiến ta phải tự gọi logic chuyển đổi sang byte, trong khi vẫn không cho phép ta gộp nhiều lệnh trong một connection.

Kết thúc

Việc tự gửi dữ liệu dưới dạng byte tới Redis có thể giúp ta tiết kiệm một phần bộ nhớ. Nhưng ta cần thực hiện benchmark kỹ càng trước khi sử dụng phương án này. Nếu không có thể nó sẽ có tác dụng ngược.

A software developer from Vietnam and is currently living in Japan.

Leave a Reply