后台任务
这个示例构建一个在后台生成报表的服务:请求把一份报表加入队列,立即得到它的 id;队列的动作负责生成报表,客户端则反复查询报表的状态,直到报表生成完毕。一个定时任务负责删除旧报表,同一时间只在一个服务实例中执行。示例从 service 模板开始,展示队列组件及其动作、重试和死信队列、定时任务、各实例共用的锁,以及测试。整个示例不需要单独的服务器:队列在内存中,报表和锁都是文件。
| 动作 | 名称 | 作用 |
|---|---|---|
POST | /reports | 把某个月的报表加入队列({"month": "2026-09"}),返回它的 id |
GET | /reports/:id | 取报表的状态(queued、running、done、failed)和结果 |
| 队列 | report.build | 生成报表:同时处理 2 份,最多尝试 3 次 |
| 队列 | report.build.dead | 把尝试次数已用完的报表标为失败 |
| 定时任务 | */5 * * * * | 删除结束超过一天的报表,只在一个实例中执行 |
1. 创建项目
npx @lakutata/cli create --name reports --template service --yes
cd reports
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
echo 'data/' >> .gitignore项目是一个工作区,服务在 packages/app 中(见 ProjectStructure)。队列(lakutata/com/queue)和锁(lakutata/com/mutex)都包含在 lakutata 包中,它们的内存驱动和文件驱动不需要其他包,所以这里什么都不用安装。这里还删掉了模板中的示例控制器,并让 git 忽略存放报表和锁的 data 目录。
2. 输入
packages/app/src/dtos/ReportDTO.ts
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
/**
* 新报表:某个月的销售额
*/
export class CreateReportDTO extends DTO {
@Expect(DTO.String().pattern(/^\d{4}-(0[1-9]|1[0-2])$/, 'YYYY-MM').required())
public month: string
}
/**
* 按 id 指定的报表
*/
export class ReportIdDTO extends DTO {
@Expect(DTO.String().uuid().required())
public id: string
}
/**
* 任务的消息:要生成的报表
*/
export class BuildReportDTO extends CreateReportDTO {
@Expect(DTO.String().uuid().required())
public id: string
}CreateReportDTO是POST /reports的请求体。pattern()的第二个参数是这条规则的名字,错误消息中会用到它:"month" with value "2026-13" fails to match the YYYY-MM pattern。BuildReportDTO是队列传递的消息:报表的 id 和月份。队列的动作和其他动作一样,用自己的 DTO 校验输入,这里的输入就是消息(见 DTOValidation)。
3. 报表
packages/app/src/components/Reports.ts
import {randomUUID} from 'node:crypto'
import {mkdir, readdir, readFile, rename, rm, writeFile} from 'node:fs/promises'
import path from 'node:path'
import {Component, DTO} from 'lakutata'
import {Configurable} from 'lakutata/decorator/di'
import {type SalesSummary} from './Sales.js'
import {ReportNotFoundException} from '../exceptions/ReportNotFoundException.js'
/**
* 一份报表:从请求到结果
*/
export interface Report {
id: string
month: string
state: 'queued' | 'running' | 'done' | 'failed'
attempts: number
createdAt: string
finishedAt?: string
result?: SalesSummary
error?: string
}
/**
* 报表:每份报表是目录中的一个 JSON 文件,同一台机器上的各个服务实例共用这个目录
*/
export class Reports extends Component {
/**
* 报表所在的目录
*/
@Configurable(DTO.String().required())
protected readonly directory: string
/**
* 已结束的报表保留多久(毫秒)
*/
@Configurable(DTO.Number().positive().required())
protected readonly keep: number
/**
* 目录不存在时创建它
*/
protected async init(): Promise<void> {
await mkdir(this.directory, {recursive: true})
}
/**
* 报表的文件
* @param id
*/
protected file(id: string): string {
return path.join(this.directory, `${id}.json`)
}
/**
* 写入报表:先写到临时文件再重命名,读取的一方就不会读到写了一半的报表
* @param report
*/
protected async save(report: Report): Promise<Report> {
const temporary: string = `${this.file(report.id)}.${process.pid}.tmp`
await writeFile(temporary, JSON.stringify(report))
await rename(temporary, this.file(report.id))
return report
}
/**
* 新报表,处于排队状态
* @param month
*/
public async create(month: string): Promise<Report> {
return await this.save({id: randomUUID(), month: month, state: 'queued', attempts: 0, createdAt: new Date().toISOString()})
}
/**
* 取一份报表,不存在时抛出 404 错误
* @param id
*/
public async get(id: string): Promise<Report> {
try {
return JSON.parse(await readFile(this.file(id), 'utf8')) as Report
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new ReportNotFoundException(`The report ${id} does not exist`)
throw error
}
}
/**
* 修改报表
* @param id
* @param changes
*/
public async update(id: string, changes: Partial<Report>): Promise<Report> {
return await this.save({...await this.get(id), ...changes})
}
/**
* 删除结束时间超过保留期限的报表
* @param now 清理的时间
* @returns 删除的报表数
*/
public async clean(now: Date = new Date()): Promise<number> {
let deleted: number = 0
for (const name of await readdir(this.directory)) {
if (!name.endsWith('.json')) continue
const report: Report = await this.get(path.basename(name, '.json'))
if (report.finishedAt && now.getTime() - Date.parse(report.finishedAt) > this.keep) {
await rm(this.file(report.id), {force: true})
deleted++
}
}
return deleted
}
}packages/app/src/exceptions/ReportNotFoundException.ts
import {Exception} from 'lakutata'
/**
* 请求的报表不存在
*/
export class ReportNotFoundException extends Exception {
public errno: string = 'E_REPORT_NOT_FOUND'
public readonly statusCode: number = 404
}报表先是 queued(排队中),然后是 running(生成中),最后要么是 done(完成)并带有结果,要么是 failed(失败)并带有错误。Reports 组件把每份报表保存为一个 JSON 文件:
- 组件只创建一次,由整个服务共用:HTTP 动作、队列动作和定时任务用的是同一个实例(见 Objects)。它的
@Configurable属性就是它的选项,在Config.ts中给出;目录由它的init()创建。 - 报表先写入临时文件,再重命名,所以请求不会读到写了一半的报表。
clean()删除结束后已超过keep时长的报表,这就是定时任务要做的工作。它把当前时间作为参数,这样测试就能模拟明天执行时的情形。- 同一台机器上的各个服务实例共用这个目录。如果服务有数据库,报表就会是一张表,方法保持不变。
4. 销售数据
packages/app/src/components/Sales.ts
import {setTimeout} from 'node:timers/promises'
import {Component} from 'lakutata'
/**
* 一个月的销售额
*/
export interface SalesSummary {
orders: number
revenue: number
}
/**
* 商店的销售数据。本例中是编造的;在真实的服务中,这里是对数据库的查询,也是生成报表时最慢的部分
*/
export class Sales extends Component {
/**
* 一个月的订单数和收入
* @param month 2026-09
* @param signal 任务被停止时中止:到了时间上限,或者服务正在停止
*/
public async summarize(month: string, signal?: AbortSignal): Promise<SalesSummary> {
//慢的部分:这里只等半秒
await setTimeout(500, undefined, {signal: signal})
//编造的数字,同一个月每次都相同
const orders: number = Number(month.replace('-', '')) % 500 + 100
return {orders: orders, revenue: orders * 37.5}
}
}报表统计一个月的订单数和收入。这里的数字是编造的,前面等待的半秒代表的是真实服务中缓慢的查询。summarize() 接收任务的信号,任务被中止时,等待也随之结束。Sales 是一个单独的组件,所以测试可以替换它的 summarize(),让任务失败。
5. 控制器
packages/app/src/controllers/ReportController.ts
import {Controller, Result, type StatusResult} from 'lakutata/com/entrypoint'
import {type Logger} from 'lakutata/com/logger'
import {type Queue, QueueAction, type QueueContext, type QueueDeadLetter} from 'lakutata/com/queue'
import {GET, POST, Prefix, Schedule} from 'lakutata/decorator/ctrl'
import {Inject} from 'lakutata/decorator/di'
import {type Report, type Reports} from '../components/Reports.js'
import {type Sales, type SalesSummary} from '../components/Sales.js'
import {BuildReportDTO, CreateReportDTO, ReportIdDTO} from '../dtos/ReportDTO.js'
@Prefix({route: '/reports'})
export class ReportController extends Controller {
/**
* 服务的队列(选项中的 "queue" 组件)
*/
@Inject('queue')
protected readonly queue: Queue
/**
* 报表("reports" 组件)
*/
@Inject('reports')
protected readonly reports: Reports
/**
* 商店的销售数据("sales" 组件)
*/
@Inject('sales')
protected readonly sales: Sales
/**
* 服务的日志
*/
@Inject('log')
protected readonly log: Logger
/**
* POST /reports {"month": "2026-09"}:报表加入队列,立即返回它的 id
* @param input
*/
@POST('/', CreateReportDTO)
public async create(input: CreateReportDTO): Promise<StatusResult> {
const report: Report = await this.reports.create(input.month)
await this.queue.add('report.build', {id: report.id, month: report.month}, {jobId: report.id})
return Result.status(202, report, {Location: `/reports/${report.id}`})
}
/**
* GET /reports/:id:报表的状态,完成后还有结果
* @param input
*/
@GET('/:id', ReportIdDTO)
public async get(input: ReportIdDTO): Promise<Report> {
return await this.reports.get(input.id)
}
/**
* 生成队列 report.build 中的报表:同时处理 2 份,最多尝试 3 次,间隔先 1 秒后 2 秒,每次最长 1 分钟
* @param input
*/
@QueueAction('report.build', BuildReportDTO, {concurrency: 2, attempts: 3, backoff: {type: 'exponential', delay: '1s'}, timeout: '1m'})
public async build(input: BuildReportDTO): Promise<SalesSummary> {
const context: QueueContext = this.context as QueueContext
await this.reports.update(input.id, {state: 'running', attempts: context.attempt})
try {
const result: SalesSummary = await this.sales.summarize(input.month, this.signal)
await this.reports.update(input.id, {state: 'done', result: result, error: undefined, finishedAt: new Date().toISOString()})
return result
} catch (error) {
//重新排队:还有尝试次数时,等待退避时间后重新投递;次数用完后发往 report.build.dead
await this.reports.update(input.id, {state: 'queued', error: (error as Error).message})
throw error
}
}
/**
* report.build 中尝试次数已用完的任务
* @param input
*/
@QueueAction('report.build.dead')
public async failed(input: QueueDeadLetter): Promise<void> {
const job: BuildReportDTO = input.input as BuildReportDTO
await this.reports.update(job.id, {state: 'failed', error: input.error.message, finishedAt: input.failedAt})
}
/**
* 每 5 分钟一次,同一时间只在一个实例中执行:删除旧报表
*/
@Schedule({cron: '*/5 * * * *', exclusive: true})
public async clean(): Promise<void> {
const deleted: number = await this.reports.clean()
this.log.info('%d old reports deleted', deleted)
}
}一个控制器包含了三种传输方式的动作:HTTP、队列和定时任务。每个请求、每条消息、每次定时执行都会得到一个新的控制器实例,组件已经注入其中(见 Controllers)。
create()保存报表,用queue.add()向队列report.build添加一条消息,不等报表生成就立即回复:Result.status()发送状态码 202(Accepted)、报表本身,以及指向这份报表的Location响应头(见 HTTP)。消息的jobId就是报表的 id。@QueueAction让build()成为队列report.build的消费者(见 Queue)。它同时处理 2 条消息(concurrency),每条最长 1 分钟(timeout:超时后this.signal被中止,消息会重试)。它的上下文是QueueContext,从中可以知道这是第几次尝试(attempt,第一次投递为 1),以及一共允许尝试几次(attempts)。- 动作正常返回时消息被确认,动作的返回值就是这条消息的结果,由队列保存。动作抛出错误时,
build()把报表改回queued状态并记下错误,消息在等待backoff(先 1 秒,再 2 秒)后重新投递,最多尝试 3 次。3 次都失败后,消息进入死信队列report.build.dead,它和普通队列一样:failed()消费其中的消息,把报表标为失败。 - 消息至少投递一次:进程崩溃后,如果使用的是有服务端的驱动,这条消息会再次投递,
build()会把同一份报表再生成一遍,这不会造成问题。 @Schedule按 cron 表达式的时间执行clean(),也就是每 5 分钟一次(见 Schedules)。设置了exclusive: true后,每次执行前先尝试从mutex组件获取锁schedule:ReportController:clean,没拿到锁的实例跳过这一次(见 Mutex)。
6. 服务的选项
packages/app/src/config/Config.ts
import path from 'node:path'
import type {ApplicationOptions} from 'lakutata'
import {buildEntrypoints, scheduleEntrypoint} from 'lakutata/com/entrypoint'
import {Mutex} from 'lakutata/com/mutex'
import {buildQueueOptions, queueEntrypoint} from 'lakutata/com/queue'
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
import compression from 'compression'
import {authenticate} from '../middlewares/Authenticate.js'
import {HealthController} from '../controllers/HealthController.js'
import {ReportController} from '../controllers/ReportController.js'
import {Reports} from '../components/Reports.js'
import {Sales} from '../components/Sales.js'
/**
* 服务的选项
* @param port HTTP 服务器的端口,默认取环境变量 PORT,没有时为 8080
* @param data 报表和锁所在的目录,默认取环境变量 DATA,没有时为 data
*/
export function config(port: number = Number(process.env.PORT ?? 8080), data: string = process.env.DATA ?? 'data'): ApplicationOptions {
return {
id: 'reports',
name: 'reports',
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
components: {
//消息保存在进程的内存中,它们的结果保留一小时
queue: buildQueueOptions({results: {keep: '1h'}}),
//锁是文件,同一台机器上的各个服务实例共用
mutex: {class: Mutex, driver: {type: 'file', directory: path.join(data, 'locks')}},
//已结束的报表保留一天
reports: {class: Reports, directory: path.join(data, 'reports'), keep: 24 * 60 * 60 * 1000},
sales: {class: Sales},
entrypoint: buildEntrypoints({
controllers: [HealthController, ReportController],
http: expressHTTPEntrypoint({
port: port,
//结果和错误都是 {errno, errMsg, data},错误保留各自的状态码(400、404 等)
respond: jsonEnvelope(),
body: {limit: '100mb'},
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
middlewares: [authenticate],
before: (app): void => void app.use(compression())
}),
//把队列的消息投递给 @QueueAction 动作
queue: queueEntrypoint('queue'),
//执行 @Schedule 动作
schedule: scheduleEntrypoint()
})
},
bootstrap: ['entrypoint']
}
}这是模板中的 Config.ts,改动如下:
- 新增
queue组件,由buildQueueOptions()生成:没有指定driver,所以消息保存在进程的内存中。队列把消息的结果保留一小时(默认一天):测试会读取其中一个结果,服务本身则从文件中读取报表; - 新增
mutex组件,使用file驱动:每把锁是data/locks中的一个文件,同一台机器上使用这个目录的各个进程共用这些锁; - 新增本例的
reports和sales组件,以及它们的选项; - 用
ReportController替换示例控制器; - 入口多了两个适配器:
queueEntrypoint('queue')把queue组件的消息投递给@QueueAction动作,scheduleEntrypoint()执行@Schedule动作(见 Entrypoint)。没有对应的适配器,装饰器就不起作用; jsonEnvelope()不再设置errorStatus,这样错误保留各自的状态码(400、404),这是 REST 客户端所期望的。
bootstrap: ['entrypoint'] 在启动时创建入口,队列也随之创建:服务启动完成后,动作就开始消费消息。模板中的中间件 Authenticate.ts 保留不变:它是 HTTP 适配器的中间件,队列的消息和定时任务的执行都不经过它。
7. 运行
在同一台机器上运行两个服务实例,它们共用 data 目录:
npm run dev第一个实例启动后,在第二个终端中运行:
PORT=8081 npm start在第三个终端中,向第一个实例提交一份报表,再从第二个实例读取:
curl -i -X POST localhost:8080/reports -H 'Content-Type: application/json' -d '{"month": "2026-09"}'
# HTTP/1.1 202 Accepted
# Location: /reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
# {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"queued","attempts":0,"createdAt":"2026-10-17T15:00:46.797Z"}}
curl localhost:8081/reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
# {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"running","attempts":1,"createdAt":"2026-10-17T15:00:46.797Z"}}
curl localhost:8081/reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
# {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"done","attempts":1,"createdAt":"2026-10-17T15:00:46.797Z","result":{"orders":209,"revenue":7837.5},"finishedAt":"2026-10-17T15:00:47.303Z"}}
curl -i -X POST localhost:8080/reports -H 'Content-Type: application/json' -d '{"month": "2026-13"}'
# HTTP/1.1 400 Bad Request
# {"errno":"E_INVALID_VALUE","errMsg":"\"month\" with value \"2026-13\" fails to match the YYYY-MM pattern","data":null}
curl -i localhost:8081/reports/5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f
# HTTP/1.1 404 Not Found
# {"errno":"E_REPORT_NOT_FOUND","errMsg":"The report 5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f does not exist","data":null}报表由收到请求的第一个实例生成,用时半秒;第二个实例读取的是它写下的文件。
每 5 分钟(:00、:05、:10……)两个实例中会有一个删除旧报表。这次是第一个实例,它在终端中输出:
app │ [23:05:00.009] INFO (reports/97338): 0 old reports deleted第二个实例没有拿到锁,跳过了这一次,日志中没有任何输出。这次没有删除任何报表:报表要保留一天。
8. 测试
模板中的测试换成了针对报表的测试。它启动服务,把报表和锁放在临时目录中:
packages/app/src/tests/App.spec.ts
import {after, before, describe, it, type TestContext} from 'node:test'
import assert from 'node:assert'
import {mkdtemp, rm} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import {setTimeout} from 'node:timers/promises'
import {Application} from 'lakutata'
import {type Queue} from 'lakutata/com/queue'
import {config} from '../config/Config.js'
import {type Report, type Reports} from '../components/Reports.js'
import {type Sales} from '../components/Sales.js'
const PORT: number = Number(process.env.TEST_PORT ?? 18087)
/**
* 向服务发一个请求,返回状态码和 {errno, errMsg, data}
* @param method
* @param route
* @param body
*/
async function request(method: string, route: string, body?: unknown): Promise<{ status: number, errno: string | null, data: any }> {
const response: Response = await fetch(`http://127.0.0.1:${PORT}${route}`, {
method: method,
headers: {'Authorization': 'Bearer test-token', 'Content-Type': 'application/json'},
body: body === undefined ? undefined : JSON.stringify(body)
})
const envelope = await response.json() as { errno: string | null, data: any }
return {status: response.status, errno: envelope.errno, data: envelope.data}
}
/**
* 反复读取一份报表,直到它的任务结束(完成或失败)
* @param id
*/
async function finished(id: string): Promise<Report> {
for (; ;) {
const report: Report = (await request('GET', `/reports/${id}`)).data
if (report.state === 'done' || report.state === 'failed') return report
await setTimeout(100)
}
}
describe('reports', (): void => {
let app: Application
let data: string
before(async (): Promise<void> => {
process.env.API_TOKEN = 'test-token'
//报表和锁放在临时目录中
data = await mkdtemp(path.join(os.tmpdir(), 'reports-'))
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
Application.run(config(PORT, data)).onLaunched((launched: Application): void => resolve(launched))
})
})
after(async (): Promise<void> => {
await app?.dispose()
await rm(data, {recursive: true, force: true})
})
it('builds a report in the background', async (): Promise<void> => {
const created = await request('POST', '/reports', {month: '2026-09'})
assert.strictEqual(created.status, 202)
assert.strictEqual(created.data.state, 'queued')
//任务的结果,由队列保存
const queue: Queue = await app.getObject<Queue>('queue')
assert.deepStrictEqual(await queue.result('report.build', created.data.id), {orders: 209, revenue: 7837.5})
const report = await request('GET', `/reports/${created.data.id}`)
assert.strictEqual(report.data.state, 'done')
assert.deepStrictEqual(report.data.result, {orders: 209, revenue: 7837.5})
assert.strictEqual(report.data.attempts, 1)
})
it('retries a job that failed', async (t: TestContext): Promise<void> => {
const sales: Sales = await app.getObject<Sales>('sales')
//第一次调用失败,之后的调用执行真正的代码
const summarize = t.mock.method(sales, 'summarize')
summarize.mock.mockImplementationOnce(async (): Promise<never> => {
throw new Error('The database did not answer')
})
const created = await request('POST', '/reports', {month: '2026-08'})
const report: Report = await finished(created.data.id)
assert.strictEqual(report.state, 'done')
assert.strictEqual(report.attempts, 2)
assert.strictEqual(summarize.mock.callCount(), 2)
})
it('fails a job after its attempts', async (t: TestContext): Promise<void> => {
const sales: Sales = await app.getObject<Sales>('sales')
t.mock.method(sales, 'summarize', async (): Promise<never> => {
throw new Error('The database did not answer')
})
const created = await request('POST', '/reports', {month: '2026-07'})
const report: Report = await finished(created.data.id)
assert.strictEqual(report.state, 'failed')
assert.strictEqual(report.attempts, 3)
assert.strictEqual(report.error, 'The database did not answer')
})
it('refuses an invalid month and an unknown report', async (): Promise<void> => {
const invalid = await request('POST', '/reports', {month: '2026-13'})
assert.strictEqual(invalid.status, 400)
assert.strictEqual(invalid.errno, 'E_INVALID_VALUE')
const unknown = await request('GET', '/reports/5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f')
assert.strictEqual(unknown.status, 404)
assert.strictEqual(unknown.errno, 'E_REPORT_NOT_FOUND')
})
it('deletes the reports finished for more than a day', async (): Promise<void> => {
//直接调用定时任务要做的工作:先用现在的时间,再用明天的时间
const reports: Reports = await app.getObject<Reports>('reports')
assert.strictEqual(await reports.clean(new Date()), 0)
assert.strictEqual(await reports.clean(new Date(Date.now() + 25 * 60 * 60 * 1000)), 3)
})
})- 第一个测试用
queue.result()读取任务的结果(它会一直等到消息处理完),然后读取报表。 - 为了让任务失败,测试用
node:test的t.mock.method()替换sales组件的summarize():一个测试只替换一次调用,任务在第二次尝试时成功;另一个测试替换所有调用,任务在 3 次尝试后失败,由于有退避等待,这需要 3 秒。组件是共用的,所以动作调用的就是替换后的方法;测试结束时方法会恢复原样。 - 最后一个测试直接调用
clean(),模拟明天执行的情形,不必等到 cron 表达式指定的时间。
npm testapp │ ✔ builds a report in the background
app │ ✔ retries a job that failed
app │ ✔ fails a job after its attempts
app │ ✔ refuses an invalid month and an unknown report
app │ ✔ deletes the reports finished for more than a day
app │ ℹ pass 5
✔ test ran in app服务会记录每次失败的尝试及其错误:The queue action ReportController:build failed on the message <id> of report.build (attempt 1 of 3)。queueEntrypoint('queue', {onError}) 可以替换这条日志。
9. 部署到多台机器
本例的实例都运行在同一台机器上。部署到多台机器时,每个部分换用一个共享的驱动即可,动作的代码不变:
- 队列:内存驱动只在一个进程中保存消息。一个实例只生成它自己收到的报表;它停止时仍在等待的消息会丢失,对应的报表停留在
queued状态,定时任务也不会删除它们。换用 Redis(driver: {type: 'redis', url: process.env.REDIS_URL},并运行npm install bullmq ioredis -w app)后,消息在重启后仍然保留,任何实例都可以处理,结果也由各实例共享(见 Queue)。 - 锁:文件驱动只在一台机器的各进程之间共享。Redis(
driver: {type: 'redis', url},并运行npm install redis -w app)或 PostgreSQL 可以在多台机器之间共享锁(见 Mutex)。 - 报表:放进数据库的一张表(见 带数据库的 REST API),或者放在一个卷上,每个实例都把它挂载为
DATA。
npm start 运行构建好的服务,npm run docker:build 用模板中的 Dockerfile 构建镜像。