Как добиться сокращения использования памяти Redis на 50%
Чтобы дать вам некоторый контекст, некоторое время назад, наша (моя организация) использовала неотслеживаемый Redis -это означает, что мы не знали, почему наша память Redis была занята так сильно. Наши 2,5 ГБ Redis ElastiCache были почти полны, и если бы он каким-то образом достиг своего предела, наша система начала бы отказывать. Хотя были и резервные варианты, Redis мог создать затор.
В этом посте я бы хотел попытаться объяснить, как мы сократили объем хранилища, занятого данными, более чем на 50%. Это также будет своего рода пошаговое руководство из основ, поэтому, если вам просто интересно, как используется Redis, просто пропустите и перейдите в раздел оптимизации.
Базовая установка
Я буду использовать последнюю версию Spring Boot от https://start.spring.io. Во - первых, выберите наши две основные зависимости - Spring Boot Web
и Spring Data Reactive Redis
Вы найдете их в файле pom.xml
при загрузке начального проекта.
Spring Boot Web
предназначен для построения основных веб-приложений с Spring Boot, в то же время Spring Data Reactive Redis
будет использоваться для подключения и использования Redis внутри приложения. По своей сути, зависимость Redis по умолчанию использует клиент Lettuce Redis и поддерживается последними версиями Spring Boot.
Обратите внимание, что я собираюсь пропустить установку Redis, так как есть другие руководства, доступные для каждой операционной системы. Вам действительно нужно, чтобы сервер Redis был запущен для успешной работы нашего приложения.
После загрузки основного приложения вам нужно будет извлечь и открыть его в своей любимой IDE (мой любимый-IntelliJ IDEA).
В моем случае имя проекта является redis-util
, и вы найдете мой "базовый пакет", под названием com.darshitpp.redis.redisutil
. Этот базовый пакет будет иметь класс RedisUtilApplication
, который в моем случае имеет следующую конфигурацию.
@SpringBootApplication
@ComponentScan(basePackages = {"com.darshitpp.redis.redisutil"})
public class RedisUtilApplication {
public static void main(String[] args) {
SpringApplication.run(RedisUtilApplication.class, args);
}
}
Я вручную добавил аннотацию @ComponentScan
, чтобы указать имя пакета верхнего уровня, под которым Spring должен искать определенные бобы / конфигурации.
Чтобы подключиться к Redis, я создаю класс конфигурации с именем LettuceRedisConfiguration
, под новым пакетом с именем configuration
(обратите внимание, что это должно быть по пути basePackages
, определенному выше).
Вы можете определить конфигурацию в самом классе RedisUtilApplication
, но я хочу, чтобы это было как можно более "готовым к производству". Таким образом, это хорошая практика, чтобы отделить ваши различные части приложения.
Мой класс конфигурации-это:
@Configuration
public class LettuceRedisConfiguration {
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(new RedisStandaloneConfiguration("localhost", 6379));
}
}
Это очень простой класс, который имеет конфигурацию того, к какому URL-адресу нужно подключиться для Redis. В моем случае это localhost
,но в большинстве производственных приложений это будет внешний сервер Redis. Порт 6379
- это порт по умолчанию, на котором запускается сервер Redis. Bean
вернуло бы нам "фабрику" соединений Redis. Подумайте об этом, как о чем-то, что позволит вам подключиться к Redis, когда это необходимо.
На этом этапе моя структура пакета выглядит следующим образом:
->src
->main
->java
->com.darshitpp.redis.redisutil
->configuration
Теперь, когда мы зная, как подключиться к серверу Redis, должны уточнить, какие данные нам нужно хранить в Redis. В нашем случае мы будем хранить данные User
. Это "модель домена" нашего приложения (модель домена может быть переведена в таблицу в базе данных, но у нас нет таблицы в нашем сценарии). User
хранится в вызванном пакете domain
.
User
будет иметь три поля, а именно firstName
, lastName
и birthday
.
Прежде чем хранить объекты в Redis, рекомендуется определить, как вы будете хранить данные, чтобы их можно было эффективно извлечь обратно. Это означает, что Redis является простым хранилищем ключей и значений, вам нужно будет определить ключ, с которым вы будете хранить значение. В нашем случае, я выбираю firstName
в качестве ключа. Данные будут храниться в хэше, так hashKey
,таким образом, hashKey
, который мы выбираем, будет lastName
, а значение, сопоставленное с hashKey
, - объектом User
.
Это связано с тем, что хэши в Redis имеют следующую структуру:
key1 --- hashKey1 === value1
--- hashKey2 === value2
--- hashKey3 === value3
key2 --- hashKey4 === value4
--- hashKey5 === value5
.
.
.
Вы также можете представить его в виде дерева с узлами верхнего уровня, являющимися ключами, непосредственными хэш-ключами следующего уровня и конечными узлами, являющимися значениями. Чтобы получить доступ к value2
, вам нужно будет иметь key1
и hashKey2
.
Наш пример немного неверен, так как User
может иметь тот же ключ = firstName
и hashKey = lastName
как другой пользователь, и Redis перезапишет value
. Однако, для краткости, мы будем считать, что есть уникальные User
, использующие наше приложение.
Теперь мы будем создавать класс контроллера NormalController
, который будет выступать в качестве точки входа для нашего API. Мы назвали его NormalController
по причинам, которые будут ясны далее в этой статье.
@RestController
@RequestMapping("/normal")
public class NormalController {
private final NormalService normalService;
@Autowired
public NormalController(NormalService normalService) {
this.normalService = normalService;
}
@GetMapping("/get")
public User get(@RequestParam("firstName") String firstName, @RequestParam("lastName") String lastName) {
return normalService.get(firstName, lastName);
}
@PostMapping("/insert")
public void insert(@RequestBody User user) {
normalService.put(user);
}
@PostMapping("/delete")
public void delete(@RequestParam("firstName") String firstName) {
normalService.delete(firstName);
}
}
NormalController
также имеет службу под названием NormalService
которая является Autowired
.
Класс должен быть определен в новом пакете с именем controller
, после которого структура пакета будет выглядеть следующим образом:
->src
->main
->java
->com.darshitpp.redis.redisutil
->configuration
->domain
->controller
Наши основные операции были бы простыми CRUD, как операции, которые NormalService
реализует с использованием пользовательского интерфейса Operations
.
public interface Operations {
User get(String firstName, String lastName);
void put(User user);
void delete(String firstName);
}
Чтобы использовать Lettuce в нашем приложении, нам нужно сделать еще несколько вещей. Так же, как для доступа к JDBC, есть положение для JdbcTemplate
, вы должны аналогичным образом использовать RedisTemplate
для работы с Redis. Мы также должны определить, в каком формате Redis будет хранить данные внутри него. По умолчанию он хранит данные в виде строки. Тем не менее, знайте, что вы будете хранить User
в Redis, и для облегчения хранения и извлечения из Redis, вам понадобится способ, с помощью которого Redis сможет идентифицировать и конвертировать его обратно в соответствующий тип данных, которые вы хотите.
Думайте об этом, как о разговоре с кем-то, кто не знает того же языка, что и вы. Если вы хотите общаться с кем-то, кто говорит только на испанском языке, вам нужно будет найти переводчика, который будет конвертировать английский язык в испанский для вас. Этот процесс преобразования и восстановления называется сериализацией и десериализацией.
Английский = > испанский = сериализация
Испанский = > английский = десериализация
Таким образом, нам нужен переводчик или сериализатор в нашем случае. Мы будем использовать Jackson для этого процесса. Jackson - это изящная библиотека, которую Spring Boot поддерживает из коробки для обработки Json.
Нам нужно было бы создать сериализатор, который implements
RedisSerializer
для наших целей. В нашем случае я создал класс JsonRedisSerializer
внутри нового пакета под названием serializer
.
class JsonRedisSerializer<T> implements RedisSerializer<T> {
public static final Charset DEFAULT_CHARSET;
private final JavaType javaType;
private ObjectMapper objectMapper = new ObjectMapper()
.registerModules(new Jdk8Module(), new JavaTimeModule(), new ParameterNamesModule(JsonCreator.Mode.PROPERTIES))
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true)
.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
public JsonRedisSerializer(Class<T> type) {
this.javaType = JavaTypeHandler.getJavaType(type);
}
public T deserialize(@Nullable byte[] bytes) throws SerializationException {
if (bytes == null || bytes.length == 0) {
return null;
} else {
try {
return this.objectMapper.readValue(bytes, 0, bytes.length, this.javaType);
} catch (Exception ex) {
throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex);
}
}
}
public byte[] serialize(@Nullable Object value) throws SerializationException {
if (value == null) {
return new byte[0];
} else {
try {
return this.objectMapper.writeValueAsBytes(value);
} catch (Exception ex) {
throw new SerializationException("Could not write JSON: " + ex.getMessage(), ex);
}
}
}
static {
DEFAULT_CHARSET = StandardCharsets.UTF_8;
}
}
Как вы можете видеть, он имеет два метода называемые serialize
и deserialize
. Каждый из этих методов использует Jackson's ObjectMapper
для преобразования.
Существует также класс с именем JavaTypeHandler
, который помогает вам получить тип объекта, который вы пытаетесь сериализовать.
final class JavaTypeHandler {
static <T> JavaType getJavaType(Class<T> clazz) {
return TypeFactory.defaultInstance().constructType(clazz);
}
}
Следовательно, нам также понадобится класс, который возвращает нам RedisTemplate
, который использует этот сериализатор. Я бы назвал этот класс RedisSerializationBuilder
.
public final class RedisSerializationBuilder {
public static <T> RedisTemplate<String, T> getNormalRedisTemplate(final LettuceConnectionFactory factory, final Class<T> clazz) {
JsonRedisSerializer<T> jsonRedisSerializer = new JsonRedisSerializer<>(clazz);
RedisTemplate<String, T> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
redisTemplate.setDefaultSerializer(RedisSerializer.json());
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(RedisSerializer.string());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
redisTemplate.setHashValueSerializer(jsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
Обратите внимание, что приведенный выше метод вернет вам шаблон, специфичный для конкретной модели домена (в нашем случае,User
), используя универсальные шаблоны. Он также определяет, какая фабрика соединений должна использоваться, что должно быть по умолчанию для сериализаторов key
/ value
/ hashKey
/ hashValue
.
Следовательно, NormalService
выглядит следующим образом:
@Service
public class NormalService implements Operations{
private final RedisTemplate<String, User> redisTemplate;
private final HashOperations<String, String, User> hashOperations;
public NormalService(LettuceConnectionFactory redisConnectionFactory) {
this.redisTemplate = RedisSerializationBuilder.getNormalRedisTemplate(redisConnectionFactory, User.class);
this.hashOperations = this.redisTemplate.opsForHash();
}
@Override
public User get(String firstName, String lastName) {
return hashOperations.get(firstName, lastName);
}
@Override
public void put(User user) {
hashOperations.put(user.getFirstName(), user.getLastName(), user);
}
@Override
public void delete(String firstName) {
hashOperations.delete(firstName);
}
}
Затем я вставил User
, используя метод POST
, и URL: localhost:8080/normalService/insert
Тело запроса:
{
"firstName": "Priscilla",
"lastName": "Haymes",
"birthday": "2020-04-12T11:15:00Z"
}
Если затем я запускаю это приложение для 100 пользователей, я нахожу следующую статистику использования памяти в Redis (я использовал команду memory stats
с помощью redis-cli
)
21) "keys.count"
22) (integer) 100
23) "keys.bytes-per-key"
24) (integer) 1044
25) "dataset.bytes"
26) (integer) 32840
Использование команды hgetall
для ключа дает мне:
127.0.0.1:6379>hgetall "Priscilla"
1) "Haymes"
2) "{\"firstName\":\"Priscilla\",\"lastName\":\"Haymes\",\"birthday\":1586690100000}"
Обратите внимание, что 2)
дает нам фактический тип данных, хранящихся в Redis - > Json!
Наша базовая структура для дальнейшей оптимизации уже существует! Ура!
Оптимизация
MessagePack здесь, чтобы прийти на помощь! Как я уже сказал, вам понадобится механизм "translation". Что делать, если переводчик является экспертом и преобразует ваш английский в испанский язык в максимально короткие слова? MessagePack - это то же самое!
Вам нужно будет добавить еще две зависимости в свой файл pom.xml
.
<dependency>
<groupId>org.msgpack</groupId>
<artifactId>msgpack-core</artifactId>
<version>0.8.20</version>
</dependency>
<dependency>
<groupId>org.msgpack</groupId>
<artifactId>jackson-dataformat-msgpack</artifactId>
<version>0.8.20</version>
</dependency>
Мы создаем контроллер под названием msgpack Controller
и сервис под названием MsgPackService
почти похожий на NormalController
и NormalService
. Мы создали MsgPackSerializer
для сериализации с помощью MessagePack.
class MsgPackRedisSerializer<T> implements RedisSerializer<T> {
public static final Charset DEFAULT_CHARSET;
private final JavaType javaType;
private ObjectMapper objectMapper = new ObjectMapper(new MessagePackFactory())
.registerModules(new Jdk8Module(), new JavaTimeModule())
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true)
.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
public MsgPackRedisSerializer(Class<T> type) {
this.javaType = JavaTypeHandler.getJavaType(type);
}
public T deserialize(@Nullable byte[] bytes) throws SerializationException {
if (bytes == null || bytes.length == 0) {
return null;
} else {
try {
return this.objectMapper.readValue(bytes, 0, bytes.length, this.javaType);
} catch (Exception ex) {
throw new SerializationException("Could not read MsgPack JSON: " + ex.getMessage(), ex);
}
}
}
public byte[] serialize(@Nullable Object value) throws SerializationException {
if (value == null) {
return new byte[0];
} else {
try {
return this.objectMapper.writeValueAsBytes(value);
} catch (Exception ex) {
throw new SerializationException("Could not write MsgPack JSON: " + ex.getMessage(), ex);
}
}
}
static {
DEFAULT_CHARSET = StandardCharsets.UTF_8;
}
}
Единственное существенное заметное изменение - это экземпляр фабрики MessagePack
, передаваемый в ObjectMapper
. Это будет действовать как мост между двоичными и строковыми форматами данных между Redis и нашим приложением Spring Boot.
Тестирование наших изменений (после очистки ранее использованного хранилища от redis дает нам следующее):
127.0.0.1:6379> hgetall "Priscilla"
1) "Haymes"
2) "\x83\xa9firstName\xa9Priscilla\xa8lastName\xa6Haymes\xa8birthday\xcf\x00\x00\x01qn\x19\x8b "
127.0.0.1:6379> memory stats
.
.
.
21) "keys.count"
22) (integer) 100
23) "keys.bytes-per-key"
24) (integer) 876
25) "dataset.bytes"
26) (integer) 15976
Сравните данные dataset.bytes
из текущей памяти с ранее записанными. 15976 байт против 32840 байт, почти 50% сокращение уже!
Но подождите, мы можем уменьшить его еще больше. Как, спросите вы. Компрессия! Что если мы сожмем данные и затем сохраним их? В нашем случае это сработало бы! На этот раз на помощь прийдет Snappy!
Ваш первый вопрос после этого будет звучать так: сжатие и декомпрессия требуют времени. Не будет ли это вредно для производства? У Snappy тоже есть ответ на этот вопрос.
Он не стремится к максимальному сжатию или совместимости с любой другой библиотекой сжатия; вместо этого он стремится к очень высоким скоростям и разумному сжатию.
Использование Snappy также просто, как добавление зависимости в pom.xml
и несколько строк изменений кода. Просто добавьте Snappy.compress
при сериализации и Snappy.decompress
при десериализации.
<dependency>
<groupId>org.xerial.snappy</groupId>
<artifactId>snappy-java</artifactId>
<version>1.1.7.3</version>
</dependency>
Тестирование его снова с теми же входными данными возвращает следующее:
127.0.0.1:6379> hgetall "Priscilla"
1) "Haymes"
2) "7\\\x83\xa9firstName\xa9Priscilla\xa8la\t\x13`\xa6Haymes\xa8birthday\xcf\x00\x00\x01qn\x19\x8b "
127.0.0.1:6379> memory stats
.
.
.
21) "keys.count"
22) (integer) 100
23) "keys.bytes-per-key"
24) (integer) 873
25) "dataset.bytes"
26) (integer) 15720
Вы можете видеть, что размер набора данных меньше, 15720 байт против 15976 байт, незначительная разница, но с большим объемом данных эта разница увеличивается.
В моем случае, очищая и реструктурируя данные, а также используя описанные выше методы, мы сократили использование памяти с 2 ГБ до менее чем 500 МБ.