如何用Redis实现一个作业队列
在如何使用Redis进行缓存中,我们实现了一个由Redis支持的简单缓存。
这只是Redis的一个用例。Redis也被用作消息传递服务器,以实现后台作业或其他类型的消息传递任务的处理。这篇文章探讨了用Quarkus和新的Redis数据源API实现这种模式。
作业队列和Supes!
一个作业队列是一个存储执行请求的数据结构。工作调度员在该数据结构中提交他们想要执行的任务。在另一边,工作消费者轮询请求并执行它们。
这种模式有很多变种,所以让我们专注于下面的应用。我们有一个管理英雄和恶棍的应用程序。该应用提供了模拟一个随机英雄和一个随机反派之间的战斗的可能性。格斗模拟被委托给格斗模拟器,即专门用于该任务的应用程序。
在这种情况下,主应用程序将战斗请求提交到工作队列中。然后,格斗模拟器轮询已提交的格斗请求并执行它们。
战斗的结果是使用另一个Redis功能进行通信的:pub/sub通信。模拟器将结果发送到应用程序所使用的通道。然后应用程序将这些结果广播到一个网页上。
这篇文章只讨论了与Redis的互动。该应用的其余部分很简单,只是使用了RESTEasy Reactive和Hibernate ORM与Panache。你可以在github.com/cescoffier/… 上找到该应用的完整代码。
提交工作
第一项任务是为作业队列建模。我们使用一个Redis列表来存储FightRequest:
package me.escoffier.quarkus.redis.fight;
public record FightRequest(String id, Hero hero, Villain villain) {
}
Redis列表区分了列表的左边和右边。这种区分允许实现一个先进先出的队列,我们在左边写,从右边消耗。
为了操作 Redis 列表,我们需要与该数据结构相关的命令组。在SupesService类中,我们注入RedisDataSource ,并检索命令组:
public SupesService(RedisDataSource dataSource, ...) {
commands = dataSource.list(FightRequest.class);
// ...
}
现在让我们看一下submitAFight 方法:
public FightRequest submitAFight() {
var hero = Hero.getRandomHero();
var villain = Villain.getRandomVillain();
var id = UUID.randomUUID().toString();
var request = new FightRequest(id, hero, villain);
commands.lpush("fight-requests", request);
return request;
}
submitAFight 方法检索随机战士,计算出一个id,建立FightRequest 实例,并执行LPUSH 命令。LPUSH 命令将给定的项目写到存储在给定键(fight-requests)的列表的左边。
接收工作请求
现在让我们来看看另一边:战斗模拟器。模拟器从代表我们工作队列的Redis列表中轮询FightRequests ,并模拟战斗。
该模拟器是用 me.escoffier.quarkus.redis.fight.FightSimulator.构造函数接收一个配置好的名字(以区分多个模拟器)和Redis数据源。它创建对象来发射Redis命令,以便从Redis列表中读取:
public FightSimulator(@ConfigProperty(name = "simulator-name") String name, RedisDataSource ds) {
this.name = name;
this.queue = ds.list(FightRequest.class);
// ...
}
模拟器轮询战斗请求,并为每个请求模拟战斗。该实现是一个无限循环(只有在应用程序关闭时才停止)。在每次迭代中,它用BRPOP 命令从队列的右侧读取待定的FightRequest 。如果没有待处理的请求,它就从循环的起点重新开始。如果有一个请求,它就模拟战斗:
@Override
public void run() {
logger.infof("Simulator %s starting", name);
while ((!stopped)) {
KeyValue<String, FightRequest> item =
queue.brpop(Duration.ofSeconds(1), "fight-requests");
if (item != null) {
var request = item.value();
var result = simulate(request);
//...
}
}
}
BRPOP 命令检索并删除列表中最后一个(右边)元素。与RPOP 不同,如果列表中没有元素,它将等待一定的时间(在上面的代码中为1秒)。所以,如果列表中包含一个元素,它就会得到它。否则,它在放弃之前最多等待一秒钟。在这种情况下,它返回null 。BRPOP 命令返回一个由列表的键和FightRequest 组成的KeyValue 。它使用这种结构是因为你可以传递多个键,这在有优先级的列表中很方便。
BRPOP 命令还可以避免在列表为空时无限期地旋转,因为它在每次迭代时都要等待1秒钟。最后,BRPOP 命令是原子性的。这意味着,如果你有多个模拟器,它们不能检索到相同的项目。它对每个项目进行一次派发。
发送战斗结果
池循环从队列中检索FightRequests ,并模拟战斗,但如何传达结果?为此,我们使用了另一个Redis功能:pub/sub通信。
简单地说,我们要把FightResult 到一个通道。订阅该通道的应用程序将收到发出的FightResult 。
一个FightResult ,包含了请求ID、两个斗士和赢家的名字:
package me.escoffier.quarkus.redis.fight;
public record FightResult(String id, Hero hero, Villain villain, String winner) {
}
为了使用Redis的pub/sub命令,我们需要与该组相关的对象。在FightSimulator ,我们也使用pubsub 方法来获得该对象:
public FightSimulator(@ConfigProperty(name = "simulator-name") String name, Logger logger, RedisDataSource ds) {
this.name = name;
this.logger = logger;
this.queue = ds.list(FightRequest.class);
this.publisher = ds.pubsub(FightResult.class); // <--- this is it!
}
现在,我们可以使用这个publisher 来发送FightResults 。每场比赛后,我们调用publisher.publish ,将FightResult 实例发送到fight-results 通道:
@Override
public void run() {
logger.infof("Simulator %s starting", name);
while ((!stopped)) {
KeyValue<String, FightRequest> item = queue.brpop(Duration.ofSeconds(1), "fight-requests");
if (item != null) {
var request = item.value();
var result = simulate(request);
publisher.publish("fight-results", result); // Send the outcome
}
}
}
接收战斗结果
在这一点上:
-
我们将战斗请求提交到工作队列中。
-
我们消耗该队列并模拟战斗。
-
我们把结果发送到
fight-results频道。
因此,唯一缺少的部分是该通道的消耗。让我们返回到 me.escoffier.quarkus.redis.supes.SupesService类。在构造函数中,我们也注入ReactiveRedisDataSource ,即Redis数据源的反应式变量。然后,在构造函数代码中,我们订阅了fight-results:
public SupesService(RedisDataSource dataSource, ReactiveRedisDataSource reactiveRedisDataSource) {
commands = dataSource.list(FightRequest.class);
stream = reactiveRedisDataSource.pubsub(FightResult.class).subscribe("fight-results")
.broadcast().toAllSubscribers();
}
因为我们使用了反应式数据源,这个订阅返回一个Multi<FightResult> ,准备由Quarkus和一个SSE(见SupesResource.java)来提供:
@GET
@Produces(MediaType.SERVER_SENT_EVENTS)
@RestStreamElementType(MediaType.APPLICATION_JSON)
public Multi<FightResult> fights() {
return supes.getFightResults();
}
.broadcast().toAllSubscribers() 指示Quarkus将所有收到的 ,广播给所有连接的SSE。所以,浏览器会过滤掉未被请求的结果。FightResult |
运行系统
这个圈子已经完成了!完整的代码源可从github.com/cescoffier/…要运行该系统,请打开三个终端。
首先,我们启动supes-application 。在第一个终端,导航到supes-application ,然后运行mvn quarkus:dev Quarkus自动启动PostgreSQL和Redis实例(如果你的机器可以运行容器)。在控制台中,点击h ,然后点击c 。它会显示正在运行的开发服务。寻找redis,并复制quarkus.redis.hosts 注入的配置:
redis-client - Up About a minute
Container: 348edec50f80/trusting_jennings docker.io/redis:7-alpine
Network: bridge - 0.0.0.0:53853->6379/tcp
Exec command: docker exec -it 348edec50f80 /bin/bash
Injected Config: quarkus.redis.hosts=redis://localhost:53853
在前面的片段中,复制:quarkus.redis.hosts=redis://localhost:53853 。这就是redis服务器的地址。我们需要用这个地址配置到模拟器上。
如果你去http://localhost:8080,网页就会被提供。你可以点击几次fights! 按钮。
战斗不会发生,因为我们没有模拟器。然而,战斗的请求已经提交并存储在列表中。所以它们并没有丢失。
现在,在第二个终端,导航到fight-simulator 目录,然后运行:
mvn package
java -Dsimulator-name=A -Dquarkus.redis.hosts=redis://localhost:53853 -jar target/quarkus-app/quarkus-run.jar
重要的是:用上面复制的那个quarkus.redis-hosts 。
一旦你启动它,它就会处理那些待处理的战斗请求:
2022-09-11 15:31:58,914 INFO [me.esc.qua.red.fig.FightSimulator] (Thread-3) Simulator A is going to simulate a fight between Pakku and Tulon Voidgazer
2022-09-11 15:31:59,786 INFO [me.esc.qua.red.fig.FightSimulator] (Thread-3) Simulator A is going to simulate a fight between Comet Zuko and Arishem The Judge (Knullified)
2022-09-11 15:32:01,809 INFO [me.esc.qua.red.fig.FightSimulator] (Thread-3) Simulator A is going to simulate a fight between Ms. America and Kazumi (Devil Form)
如果你回到网页上,获胜者会得到一个光环:
现在,在第三个终端,导航到fight-simulator 目录,并运行:
java -Dsimulator-name=B -Dquarkus.redis.hosts=redis://localhost:53853 -jar target/quarkus-app/quarkus-run.jar
重要的是:和前面的命令一样,用上面复制的命令更新quarkus.redis-hosts 。
回到网页上,点击几次fight! 按钮。检查两个模拟器的日志,看看战斗请求现在是否在两个模拟器之间发送了。
总结
这个帖子解释了你如何用Redis和Quarkus Redis数据源API实现一个作业队列。
从Quarkus文档中了解更多关于Redis数据源API的信息。我们将发布更多关于Redis模式的内容,敬请关注!