今天我们将完成第四个 JRU 模块的最终项目。会是什么?让我们尝试使用不同的技术:MySQL、Hibernate、Redis、Docker。现在更多的主题。
任务:我们有一个带有模式的关系型 MySQL 数据库(国家-城市,按国家/地区划分的语言)。而且有频繁的城市请求,速度变慢。我们想出了一个解决方案——将所有被频繁请求的数据移动到 Redis 中(以 key-value 类型的内存存储)。
而且我们不需要存储在 MySQL 中的所有数据,而只需要一组选定的字段。该项目将采用教程的形式。也就是在这里提出问题,马上解决。
那么,让我们从我们需要的软件开始:
- IDEA Ultimate(谁用完了钥匙 - 在 Slack 中写信给 Roman)
- Workbench(或任何其他 MySQL 客户端)
- 码头工人
- redis-insight - 可选
我们的行动计划:
- 设置docker(我不会在教程中这样做,因为每个操作系统都会有自己的特点,网上有很多关于“如何在windows上安装docker”等问题的答案),检查一切是否正常。
- 将 MySQL 服务器作为 docker 容器运行。
- 展开转储。
- 在Idea中创建一个项目,添加maven依赖。
- 制作图层域。
- 编写一个方法从 MySQL 中获取所有数据。
- 编写一个数据转换方法(在 Redis 中,我们将只编写经常请求的数据)。
- 将 Redis 服务器作为 docker 容器运行。
- 向 Redis 写入数据。
- 可选:安装redis-insight,查看Redis存储的数据。
- 编写一个从 Redis 获取数据的方法。
- 编写一个从 MySQL 获取数据的方法。
- 比较从 MySQL 和 Redis 获取相同数据的速度。
码头工人设置
Docker 是一个用于开发、交付和运行应用程序的开放平台。我们将使用它不是为了在本地机器上安装和配置 Redis,而是为了使用现成的图像。您可以在此处阅读有关 docker 的更多信息或在此处查看。如果您不熟悉 docker,我建议您只看第二个链接。
为确保已安装和配置 docker,请运行以下命令:docker -v
如果一切正常,你会看到docker版本

将 MySQL 服务器作为 docker 容器运行
为了能够比较MySQL和Redis返回数据的时间,我们也会在docker中使用MySQL。在 PowerShell(或另一个控制台终端,如果您不使用 Windows),运行命令:
docker run --name mysql -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root --restart unless-stopped -v mysql:/var/lib/mysql mysql:8
考虑一下我们正在用这个命令做什么:
docker run
– 启动(并下载,如果尚未下载到本地机器)图像。作为启动的结果,我们得到了一个正在运行的容器。--name mysql
- 设置 mysql 容器的名称。-d
- 一个表示容器应该继续工作的标志,即使您关闭了启动该容器的终端窗口。-p 3306:3306
- 指定端口。冒号前 - 本地机器上的端口,冒号后 - 容器中的端口。-e MYSQL_ROOT_PASSWORD=root
– 将值为 root 的环境变量 MYSQL_ROOT_PASSWORD 传递给容器。特定于 mysql/ 图像的标志--restart unless-stopped
- 设置行为策略(容器关闭时是否应该重新启动)。unless-stopped 值意味着总是重新启动,除非容器被停止/-v mysql:/var/lib/mysql
– 添加卷(用于存储信息的图像)。mysql:8
– 图像的名称及其版本。
在终端执行命令后,docker会下载镜像的所有层并启动容器:

重要说明:如果您在本地计算机上将 MySQL 作为服务安装并且正在运行,则需要在启动命令中指定不同的端口,或者停止该正在运行的服务。

展开转储
要扩展转储,您需要从 Workbench 创建一个到数据库的新连接,您可以在其中指定参数。我使用了默认端口(3306),没有更改用户名(默认为root),并为root用户(root)设置了密码。

在 Workbench 中,执行Data Import/Restore
并选择Import from Self Contained File
。指定将转储下载为文件的位置。您不需要事先创建架构 - 它的创建包含在转储文件中。成功导入后,您将拥有一个包含三个表的世界架构:
- city 是一张城市表。
- 国家 - 国家表。
- country_language - 一个表,指示该国家/地区中使用特定语言的人口百分比。

由于我们在启动容器时使用了一个卷,在停止甚至删除 mysql 容器并重新执行启动命令(docker run --name mysql -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root --restart unless-stopped -v mysql:/var/lib/mysql mysql:8
)后,将不需要再次部署转储 - 它已经部署在卷中。
在Idea中创建项目,添加maven依赖
您已经知道如何在 Idea 中创建项目——这是今天项目中最简单的一点。

在pom文件中添加依赖:
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core-jakarta</artifactId>
<version>5.6.14.Final</version>
</dependency>
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.1</version>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.2.2.RELEASE</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.14.0</version>
</dependency>
</dependencies>
前三个依赖你早就熟悉了。
lettuce-core
是与 Redis 一起工作的可用 Java 客户端之一。
jackson-databind
– 使用 ObjectMapper 的依赖项(转换数据以存储在 Redis 中(字符串类型的键值))。
同样在资源文件夹(src/main/resources)中添加 spy.properties 以查看 Hibernate 执行的带有参数的请求。文件内容:
driverlist=com.mysql.cj.jdbc.Driver
dateformat=yyyy-MM-dd hh:mm:ss a
appender=com.p6spy.engine.spy.appender.StdoutLogger
logMessageFormat=com.p6spy.engine.spy.appender.MultiLineFormat
制作图层域
创建包 com.codegym.domain
我在实体上映射表时方便使用Idea中的表结构,所以我们在Idea中添加一个数据库连接。


我建议按以下顺序创建实体:
- 国家
- 城市
- 国家语言
您最好自己执行映射。
国家类别代码:
package com.codegym.domain;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.Set;
@Entity
@Table(schema = "world", name = "country")
public class Country {
@Id
@Column(name = "id")
private Integer id;
private String code;
@Column(name = "code_2")
private String alternativeCode;
private String name;
@Column(name = "continent")
@Enumerated(EnumType.ORDINAL)
private Continent continent;
private String region;
@Column(name = "surface_area")
private BigDecimal surfaceArea;
@Column(name = "indep_year")
private Short independenceYear;
private Integer population;
@Column(name = "life_expectancy")
private BigDecimal lifeExpectancy;
@Column(name = "gnp")
private BigDecimal GNP;
@Column(name = "gnpo_id")
private BigDecimal GNPOId;
@Column(name = "local_name")
private String localName;
@Column(name = "government_form")
private String governmentForm;
@Column(name = "head_of_state")
private String headOfState;
@OneToOne
@JoinColumn(name = "capital")
private City city;
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "country_id")
private Set<CountryLanguage> languages;
//Getters and Setters omitted
}
代码中有 3 个有趣的点。
第一个是 Continent enam,它作为序数值存储在数据库中。在国家表的结构中,在大陆字段的注释中,可以看到哪个数值对应哪个大陆。
package com.codegym.domain;
public enum Continent {
ASIA,
EUROPE,
NORTH_AMERICA,
AFRICA,
OCEANIA,
ANTARCTICA,
SOUTH_AMERICA
}
第二点是一组实体CountryLanguage
。@OneToMany
这是本模块第二稿中没有的链接。默认情况下,Hibernate 在请求国家/地区实体时不会提取此集合的值。但是由于我们需要从关系数据库中减去所有的值进行缓存,所以FetchType.EAGER
.
三是城域。沟通@OneToOne
- 就像一切都是熟悉和可以理解的。但是,如果我们查看数据库中的外键结构,我们会看到国家(国家)与首都(城市)有联系,城市(城市)与国家(国家)有联系。存在循环关系。
我们不会对此做任何事情,但是当我们到达“编写一个从 MySQL 获取所有数据的方法”项目时,让我们看看 Hibernate 执行了哪些查询,查看它们的数量,并记住这个项目。Hibernate
市级代码:
package com.codegym.domain;
import jakarta.persistence.*;
@Entity
@Table(schema = "world", name = "city")
public class City {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String name;
@ManyToOne
@JoinColumn(name = "country_id")
private Country country;
private String district;
private Integer population;
//Getters and Setters omitted
}
CountryLanguage 类代码:
package com.codegym.domain;
import jakarta.persistence.*;
import org.hibernate.annotations.Type;
import java.math.BigDecimal;
@Entity
@Table(schema = "world", name = "country_language")
public class CountryLanguage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Integer id;
@ManyToOne
@JoinColumn(name = "country_id")
private Country country;
private String language;
@Column(name = "is_official", columnDefinition = "BIT")
@Type(type = "org.hibernate.type.NumericBooleanType")
private Boolean isOfficial;
private BigDecimal percentage;
//Getters and Setters omitted
}
写一个方法从MySQL中获取所有数据
在 Main 类中,我们声明字段:
private final SessionFactory sessionFactory;
private final RedisClient redisClient;
private final ObjectMapper mapper;
private final CityDAO cityDAO;
private final CountryDAO countryDAO;
并在 Main 类的构造函数中初始化它们:
public Main() {
sessionFactory = prepareRelationalDb();
cityDAO = new CityDAO(sessionFactory);
countryDAO = new CountryDAO(sessionFactory);
redisClient = prepareRedisClient();
mapper = new ObjectMapper();
}
如您所见,没有足够的方法和类 - 让我们编写它们。
声明一个包 com.codegym.dao 并向其添加 2 个类:
package com.codegym.dao;
import com.codegym.domain.Country;
import org.hibernate.SessionFactory;
import org.hibernate.query.Query;
import java.util.List;
public class CountryDAO {
private final SessionFactory sessionFactory;
public CountryDAO(SessionFactory sessionFactory) {
this.sessionFactory = sessionFactory;
}
public List<Country> getAll() {
Query<Country> query = sessionFactory.getCurrentSession().createQuery("select c from Country c", Country.class);
return query.list();
}
}
package com.codegym.dao;
import com.codegym.domain.City;
import org.hibernate.SessionFactory;
import org.hibernate.query.Query;
import java.util.List;
public class CityDAO {
private final SessionFactory sessionFactory;
public CityDAO(SessionFactory sessionFactory) {
this.sessionFactory = sessionFactory;
}
public List<City> getItems(int offset, int limit) {
Query<City> query = sessionFactory.getCurrentSession().createQuery("select c from City c", City.class);
query.setFirstResult(offset);
query.setMaxResults(limit);
return query.list();
}
public int getTotalCount() {
Query<Long> query = sessionFactory.getCurrentSession().createQuery("select count(c) from City c", Long.class);
return Math.toIntExact(query.uniqueResult());
}
}
现在您可以将这 2 个类导入到 Main 中。仍然缺少两种方法:
private SessionFactory prepareRelationalDb() {
final SessionFactory sessionFactory;
Properties properties = new Properties();
properties.put(Environment.DIALECT, "org.hibernate.dialect.MySQL8Dialect");
properties.put(Environment.DRIVER, "com.p6spy.engine.spy.P6SpyDriver");
properties.put(Environment.URL, "jdbc:p6spy:mysql://localhost:3306/world");
properties.put(Environment.USER, "root");
properties.put(Environment.PASS, "root");
properties.put(Environment.CURRENT_SESSION_CONTEXT_CLASS, "thread");
properties.put(Environment.HBM2DDL_AUTO, "validate");
properties.put(Environment.STATEMENT_BATCH_SIZE, "100");
sessionFactory = new Configuration()
.addAnnotatedClass(City.class)
.addAnnotatedClass(Country.class)
.addAnnotatedClass(CountryLanguage.class)
.addProperties(properties)
.buildSessionFactory();
return sessionFactory;
}
我们还没有到达萝卜,所以萝卜客户端的初始化实现暂时保留为存根:
private void shutdown() {
if (nonNull(sessionFactory)) {
sessionFactory.close();
}
if (nonNull(redisClient)) {
redisClient.shutdown();
}
}
最后,我们可以写一个方法来拉出所有的城市:
private List<City> fetchData(Main main) {
try (Session session = main.sessionFactory.getCurrentSession()) {
List<City> allCities = new ArrayList<>();
session.beginTransaction();
int totalCount = main.cityDAO.getTotalCount();
int step = 500;
for (int i = 0; i < totalCount; i += step) {
allCities.addAll(main.cityDAO.getItems(i, step));
}
session.getTransaction().commit();
return allCities;
}
}
实现特征是我们每个得到 500 个城市。这是必要的,因为对传输的数据量有限制。是的,就我们而言,我们不会接触到它们,因为。我们的数据库中共有 4079 个城市。但是在生产应用中,当需要获取大量数据时,往往会用到这种技术。
以及主要方法的实现:
public static void main(String[] args) {
Main main = new Main();
List<City> allCities = main.fetchData(main);
main.shutdown();
}
现在我们可以第一次在调试中运行我们的应用程序,看看它是如何工作的(或不工作——是的,它经常发生)。

城市越来越多。每个城市都会得到一个国家,如果它之前没有从另一个城市的数据库中减去的话。让我们粗略地计算一下 Hibernate 将向数据库发送多少查询:
- 1 请求找出城市总数(需要迭代 500 多个城市才能知道何时停止)。
- 4079 / 500 = 9 个请求(城市列表)。
- 如果之前没有减去每个城市,则每个城市都会得到一个国家。由于数据库中有 239 个国家/地区,这将为我们提供 239 个查询。
Total 249 requests
. 而且我们也说过,和国家一起,我们马上会收到一套语言,不然就普遍黑暗了。但它仍然很多,所以让我们稍微调整一下行为。让我们从思考开始:做什么,去哪里跑?但说真的——为什么会有这么多请求。如果您查看请求日志,我们会看到每个国家/地区都是单独请求的,因此第一个简单的解决方案:让我们一起请求所有国家/地区,因为我们事先知道我们将在此交易中需要所有国家/地区。
在 fetchData() 方法中,在事务开始后立即添加以下行:
List<Country> countries = main.countryDAO.getAll();
我们计算请求:
- 1 - 获取所有国家
- 239 - 查询其首都的每个国家
- 1 - 请求城市数量
- 9 - 请求城市列表
Total 250
. 想法很好,但没有奏效。问题是国家与首都(城市)有联系@OneToOne
。默认情况下会立即加载此类链接 ( FetchType.EAGER
)。让我们把FetchType.LAZY
, 因为 无论如何,我们稍后会在同一个事务中加载所有城市。
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "capital")
private City city;
资本不再单独请求,但请求的数量没有改变。现在,对于每个国家/地区,CountryLanguage列表由单独的查询请求。也就是有进步,我们在朝着正确的方向前进。如果您还记得的话,讲座建议使用“连接获取”解决方案,以便通过向请求添加额外的连接来在一个请求中请求具有依赖数据的实体。在CountryDAO中,将方法中的 HQL 查询重写getAll()
为:
"select c from Country c join fetch c.languages"
发射。我们查看日志,统计请求数:
- 1 - 所有有语言的国家
- 1 - 城市数量
- 9 - 城市列表。
Total 11
- 我们成功了))如果您不仅阅读了所有这些文本,而且在调整应用程序的每一步后都尝试运行它,您甚至应该多次直观地注意到整个应用程序的加速。
写一个数据转换方法
让我们创建一个包com.codegym.redis
,在其中添加 2 个类:CityCountry(关于城市和该城市所在国家/地区的数据)和Language(关于语言的数据)。这里列出了“制动请求”中“按任务”经常请求的所有字段。
package com.codegym.redis;
import com.codegym.domain.Continent;
import java.math.BigDecimal;
import java.util.Set;
public class CityCountry {
private Integer id;
private String name;
private String district;
private Integer population;
private String countryCode;
private String alternativeCountryCode;
private String countryName;
private Continent continent;
private String countryRegion;
private BigDecimal countrySurfaceArea;
private Integer countryPopulation;
private Set<Language> languages;
//Getters and Setters omitted
}
package com.codegym.redis;
import java.math.BigDecimal;
public class Language {
private String language;
private Boolean isOfficial;
private BigDecimal percentage;
//Getters and Setters omitted
}
在主要方法中,在获取所有城市后,添加行
List<CityCountry>> preparedData = main.transformData(allCities);
并实现这个方法:
private List<CityCountry> transformData(List<City> cities) {
return cities.stream().map(city -> {
CityCountry res = new CityCountry();
res.setId(city.getId());
res.setName(city.getName());
res.setPopulation(city.getPopulation());
res.setDistrict(city.getDistrict());
Country country = city.getCountry();
res.setAlternativeCountryCode(country.getAlternativeCode());
res.setContinent(country.getContinent());
res.setCountryCode(country.getCode());
res.setCountryName(country.getName());
res.setCountryPopulation(country.getPopulation());
res.setCountryRegion(country.getRegion());
res.setCountrySurfaceArea(country.getSurfaceArea());
Set<CountryLanguage> countryLanguages = country.getLanguages();
Set<Language> languages = countryLanguages.stream().map(cl -> {
Language language = new Language();
language.setLanguage(cl.getLanguage());
language.setOfficial(cl.getOfficial());
language.setPercentage(cl.getPercentage());
return language;
}).collect(Collectors.toSet());
res.setLanguages(languages);
return res;
}).collect(Collectors.toList());
}
我认为这种方法是不言自明的:我们只是创建一个CityCountry实体并用来自City、Country、CountryLanguage的数据填充它。
将 Redis 服务器作为 docker 容器运行
这里有 2 个选项。如果您执行可选步骤“安装 redis-insight,查看存储在 Redis 中的数据”,那么该命令适合您:
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
如果您决定跳过此步骤,则只需:
docker run -d --name redis -p 6379:6379 redis:latest
不同之处在于,在第一个选项中,端口 8001 被转发到本地计算机,您可以连接到外部客户端以查看内部存储的内容。因此,我曾经给出有意义的名字,redis-stack
或者redis
。
启动后,您可以看到正在运行的容器列表。为此,请运行以下命令:
docker container ls
你会看到这样的东西:

如果您需要查找一些命令,您可以查看终端中的帮助(docker help)或谷歌“how to ...”(例如,docker how to remove running container)。
而我们也在Main构造函数中调用了萝卜客户端的初始化,但是并没有实现方法本身。添加实现:
private RedisClient prepareRedisClient() {
RedisClient redisClient = RedisClient.create(RedisURI.create("localhost", 6379));
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
System.out.println("\nConnected to Redis\n");
}
return redisClient;
}
添加 sout 是出于教育目的,以便在启动日志中您可以看到一切正常,并且通过萝卜客户端的连接没有错误。
向 Redis 写入数据
添加对 main 方法的调用
main.pushToRedis(preparedData);
使用此方法实现:
private void pushToRedis(List<CityCountry> data) {
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
RedisStringCommands<String, String> sync = connection.sync();
for (CityCountry cityCountry : data) {
try {
sync.set(String.valueOf(cityCountry.getId()), mapper.writeValueAsString(cityCountry));
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
}
}
在这里,与萝卜客户端建立同步连接,并依次将CityCountry类型的每个对象写入萝卜。由于萝卜是一个String key-value store ,key (city id) 被转换为一个字符串。并且该值也是字符串,但是使用JSON 格式的ObjectMapper。
它仍然运行并检查日志中是否没有错误。一切正常。
安装redis-insight,查看Redis中存储的数据(可选)
从链接下载 redis-insight 并安装它。启动后,它立即在 docker 容器中显示我们的萝卜实例:

如果您登录,我们将看到所有密钥的列表:

您可以转到任意键以查看其上存储了哪些数据:

写一个从Redis获取数据的方法
为了进行测试,我们使用以下测试:我们获得 10 条 CityCountry 记录。每个都有一个单独的请求,但在一个连接中。
萝卜的数据可以通过我们的萝卜客户端获取。为此,让我们编写一个方法来获取要获取的 id 列表。
private void testRedisData(List<Integer> ids) {
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
RedisStringCommands<String, String> sync = connection.sync();
for (Integer id : ids) {
String value = sync.get(String.valueOf(id));
try {
mapper.readValue(value, CityCountry.class);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
}
}
我认为,实现是直观的:我们打开一个同步连接,对于每个id,我们得到一个 JSON 字符串,我们将其转换为我们需要的CityCountry类型的对象。
写一个方法从MySQL中获取数据
在CityDAO类中,添加一个方法getById(Integer id)
,我们将在其中获取城市和国家/地区:
public City getById(Integer id) {
Query<City> query = sessionFactory.getCurrentSession().createQuery("select c from City c join fetch c.country where c.id = :ID", City.class);
query.setParameter("ID", id);
return query.getSingleResult();
}
类比上一段,我们在Main类中添加一个类似MySQL的方法:
private void testMysqlData(List<Integer> ids) {
try (Session session = sessionFactory.getCurrentSession()) {
session.beginTransaction();
for (Integer id : ids) {
City city = cityDAO.getById(id);
Set<CountryLanguage> languages = city.getCountry().getLanguages();
}
session.getTransaction().commit();
}
}
在功能中,为了确保获得完整的对象(没有代理存根),我们明确要求国家/地区提供语言列表。
比较MySQL和Redis获取相同数据的速度
这里我马上给出main方法的代码,以及在我本地电脑上得到的结果。
public static void main(String[] args) {
Main main = new Main();
List<City> allCities = main.fetchData(main);
List<CityCountry> preparedData = main.transformData(allCities);
main.pushToRedis(preparedData);
// close the current session in order to make a query to the database for sure, and not to pull data from the cache
main.sessionFactory.getCurrentSession().close();
//choose random 10 id cities
//since we did not handle invalid situations, use the existing id in the database
List<Integer> ids = List.of(3, 2545, 123, 4, 189, 89, 3458, 1189, 10, 102);
long startRedis = System.currentTimeMillis();
main.testRedisData(ids);
long stopRedis = System.currentTimeMillis();
long startMysql = System.currentTimeMillis();
main.testMysqlData(ids);
long stopMysql = System.currentTimeMillis();
System.out.printf("%s:\t%d ms\n", "Redis", (stopRedis - startRedis));
System.out.printf("%s:\t%d ms\n", "MySQL", (stopMysql - startMysql));
main.shutdown();
}
测试时,有一个特性——来自 MySQL 的数据只被读取,所以它不能在我们的应用程序启动之间重新启动。在 Redis 中,它们被写入。
虽然当您尝试为同一个键添加副本时,数据只会被更新,但我建议您在终端中的应用程序启动之间运行命令来停止容器docker stop redis-stack
并删除容器docker rm redis-stack
。之后,再次举起装有萝卜的容器docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
,然后才执行我们的应用程序。
这是我的测试结果:

总的来说,我们实现了对“频繁刹车”请求的响应性能提升一倍半。这是考虑到这样一个事实,即在测试中我们没有通过ObjectMapper使用最快的反序列化。如果将其更改为 GSON,很可能您可以“赢得”更多时间。
这一刻,我想起了一个关于一个程序员和时间的笑话:阅读并思考如何编写和优化你的代码。
GO TO FULL VERSION