Note: see the link below for the English version of this article.
https://duongnt.com/redis-raw-bytes
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ệuDouble
). - 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 Long
và Double
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.