caneara/waterfall

此包已被废弃,不再维护。没有建议的替代包。

一个执行批量级联删除以减轻数据库压力的包

v2.0.2 2022-04-23 14:28 UTC

This package is auto-updated.

Last update: 2024-04-04 14:38:15 UTC


README

此包允许 Laravel 应用程序以分批延迟的方式执行数据库级联删除操作。这种方法的主要优点是,在操作大规模应用程序(例如分析平台)时,可以避免因大量记录删除任务而使数据库不堪重负。

这是为谁准备的?

如果您正在构建一个小型应用程序,或者您的数据库不太可能看到超过几千条记录的级联删除,那么您可能不需要这个包。只需像平常一样在迁移中强制执行级联删除即可。

如果您的数据库包含或将包含数十万、数百万甚至数十亿条记录,并且删除一条记录将涉及数量相似的记录级联,那么这可能会使您的数据库不堪重负。在这种情况下,Waterfall 可以是一个不错的选择。

如果您使用的是所谓的“NewSQL”平台(例如,由于缺乏外键约束),它不提供级联删除,那么这个包也是一个不错的选择。

它是如何工作的?

这个过程相当简单。您定义一个工作,例如 DeleteUserJob,它扩展了 Waterfall 自身的 Waterfall\Jobs\Job。在这个工作内部,您配置需要执行级联任务。当您准备好删除一个“用户”记录时,您调度 DeleteUserJob 并提供该“用户”的 ID。

然后 Waterfall 将执行以下任务

  1. 软删除主记录(用户)。
  2. 迭代级联任务。
    1. 删除一批相关记录(1000)。
    2. 如果有更多记录,短暂睡眠后,调度另一个工作来重复(i)。
    3. 如果没有更多记录,短暂睡眠后,调度另一个工作来继续 2。
  3. 当所有任务都完成后,硬删除主记录(用户)。

安装

使用 Composer 拉取包

composer require caneara/waterfall

升级

第 2 版引入了一种完全不同的任务配置方式。在第 1 版中,任务是通过在 create 工厂方法上设置一系列参数来配置的,然而,随着钩子的添加,这变得相当繁琐。

因此,Task 类被重构以提供一系列可以链接在一起来构建任务的 setter 方法。建议在升级时重新阅读整个 README。

配置

Waterfall 包含一个配置文件,允许您

  1. 设置用于级联删除作业的队列名称(默认为 'deletion')。
  2. 设置每查询要删除的批次大小/记录数(默认为 1000)。
  3. 设置在批次之间给数据库的休息时间(秒数,默认为 5)。

如果您希望更改这些值中的任何一个,请使用 Artisan 发布配置文件

php artisan vendor:publish

请注意,从第 2 版开始,您可以在单个任务内覆盖批次大小和休息时间。因此,如果您打算为您的任务设置自定义值,并且对默认队列名称感到满意,则无需发布配置文件。

队列

请确保只为队列创建少量工作者,例如 2 或 3。太多的工作者会风险使数据库不堪重负,这完全违背了包的目的。

使用

为了使Waterfall在删除记录时不会触发级联删除,相关的Model类必须实现Laravel内置的软删除功能。首先,将SoftDeletes特性添加到模型类中,例如:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class User extends Model
{
    use SoftDeletes;
}

接下来,我们需要创建一个扩展Waterfall\Jobs\Job的任务,例如:

<?php

namespace App\Jobs;

use App\Models\User;
use Waterfall\Jobs\Job;

class DeleteUserJob extends Job
{
    public static string $type = User::class;
}

注意任务中包含的$type属性。这应该设置为被删除主记录的模型类型。在这个例子中,我们正在删除一个用户,所以$typeUser::class

创建任务

目前,这个任务只会软删除用户,然后硬删除。如果没有中间任务,这仍然会触发级联删除。为了防止这种情况,让我们添加一些任务。我们通过在任务中添加一个tasks()方法,然后创建一个或多个Waterfall\Tasks\Task来实现。

设置相关模型

我们的第一步是设置相关模型(例如,一个用户有多个帖子,所以模型将是Post::class)。我们通过使用model方法来实现,例如:

use Waterfall\Tasks\Task;

protected function tasks() : array
{
    return [
        Task::create()
            ->model(Post::class)
    ];
}

Waterfall将按照以下方式解释:

DELETE FROM `posts` WHERE `user_id` = ? LIMIT 1000

如果您愿意,也可以使用table方法,例如:

use Waterfall\Tasks\Task;

protected function tasks() : array
{
    return [
        Task::create()
            ->table('posts')
    ];
}

配置外键

注意Waterfall是如何通过我们为$type定义的类来猜测外键的。在许多情况下,这将正确。但是,如果您需要使用不同的键,则可以显式使用key方法来设置它。

protected function tasks() : array
{
    return [
        Task::create()
            ->model(Post::class)
            ->key('author_id')
    ];
}

修改查询

在许多情况下,您只想删除与主记录关联的所有记录。但是,如果您需要更具体一些,例如,您想要包含一个WHERE条件,或者添加一个JOIN,则可以修改查询。

为此,调用query方法并提供一个接受当前$query作为参数的Closure。然后您可以自由地按任何方式修改查询,例如:

protected function tasks() : array
{
    return [
        Task::create()
            ->model(Post::class)
            ->key('author_id')
            ->query(function($query) {
                return $query->where('year', 2022);
            })
    ];
}

Waterfall将按照以下方式解释:

DELETE FROM `posts` WHERE `author_id` = ? AND `year` = 2022 LIMIT 1000

调整批处理大小和休息时间

默认情况下,Waterfall将使用其配置文件中定义的批处理大小和休息时间(您可以根据需要发布并修改它)。但是,如果您需要的话,可以为单个任务覆盖这些值。这特别有用,如果您正在使用钩子(我们将在稍后探讨)。

要更改批处理大小或休息时间,分别调用batchrest方法,例如:

protected function tasks() : array
{
    return [
        Task::create()
            ->model(Post::class)
            ->batch(10) // retrieve up to 10 records at a time
            ->rest(15)  // delay follow-up jobs for the task by 15 seconds
    ];
}

rest方法还接受一个Carbon实例,例如:

Task::create()->rest(300)                  // 5 minutes
Task::create()->rest(now()->addMinutes(5)) // 5 minutes

将钩子添加到您的任务中

在删除相关记录时,您可能需要执行额外的步骤。例如,博客文章可能将横幅图像存储在磁盘上。当删除博客文章时,我们需要删除这些图像。Waterfall通过包含钩子功能来实现这一点。

您可以在批量删除之前、之后或两者之间挂钩到任务。为此,调用beforeafter方法,并提供一个接受$items参数的Closure$items参数是一个包含当前批处理记录的Collection实例。

出于性能和内存考虑,记录作为简单对象检索。如果您需要模型,则在任务上调用hydrate方法,例如:Task::create()->model(Post::class)->hydrate()->before(...)

让我们看看如何在上面的例子中删除横幅图像

protected function tasks() : array
{
    return [
        Task::create()
            ->model(Post::class)
            ->before(function($items) {
                $items->each(function($post) {
                    Storage::delete($post->banner_path);
                })
            })
    ];
}

重要的是要理解,使用钩子需要Waterfall从数据库中获取记录批次,例如执行一个SELECT查询。当没有使用钩子时,Waterfall只需执行一个DELETE查询,这要高效得多。

如果您想使用钩子,那么有一些重要的事情需要记住

  1. 对于大记录,您可能会耗尽内存。因此,请确保设置一个较小的批处理大小。
  2. 如果您有大量记录,只需要ID或列的子集,那么请考虑使用query方法仅选择所需的字段。这将减少对数据库的压力,并降低内存耗尽的风险。
  3. 钩子需要额外的处理时间,因此请确保为您的作业提供足够的超时时间。

如何排序您的任务

为了防止级联删除操作发生,您必须以相反的顺序执行删除任务。为了更好地说明这一点,考虑以下示例数据库

users -> posts -> likes

如果您要删除一个'用户',这将触发级联删除该用户的所有'帖子',然后删除用户'帖子'积累的所有'赞'。同样,如果您只是删除一个'帖子',它不会删除'用户',但它将导致级联删除'帖子'积累的所有'赞'。因此,我们必须按照以下顺序执行删除任务

likes -> posts -> users

以下是一个完整的作业示例,介绍了如何执行此操作。请注意,这里假设'posts'有一个author_id外键,'likes'有一个post_id外键。

<?php

namespace App\Jobs;

use App\Models\Like;
use App\Models\Post;
use App\Models\User;
use Waterfall\Jobs\Job;
use Waterfall\Tasks\Task;

class DeleteUserJob extends Job
{
    public static string $type = User::class;

    protected function tasks() : array
    {
        return [
            Task::create()
                ->model(Like::class)
                ->key('posts.author_id')
                ->query(function($query) {
                    return $query->join('posts', 'likes.post_id', '=', 'posts.id');
                })),

            Task::create()
                ->model(Post::class)
                ->key('author_id')
                ->query(function($query) {
                    return $query->select(['id', 'author_id', 'banner_path']);
                })),
                ->before(function($items) {
                    $items->each(function($post) {
                        Storage::delete($post->banner_path);
                    })
                })
        ];
    }
}

Waterfall将按照以下方式解释:

DELETE FROM `likes` INNER JOIN `posts` ON `likes`.`post_id` = `posts`.`id` WHERE `posts`.`author_id` = ? LIMIT 1000
DELETE FROM `posts` WHERE `author_id` = ? LIMIT 1000

分发作业

一旦配置了所有任务并在主作业类上设置了$type,剩下的就是分发作业并提供要删除的记录的ID。

继续我们的示例,如果我们想要删除ID为6的用户,我们会运行以下代码

DeleteUserJob::dispatch(6);

在数据库级别启用级联删除

关于是否在数据库迁移中继续启用级联删除,存在不同的观点。

理论上,如果您使用此包并且您的作业配置正确,则不需要启用级联删除。然而,如果作业遗漏了某些内容,并且您已禁用级联删除,则数据库将引发错误(可能留下损坏的数据)。

另一种方法是,在开发中禁用级联删除,然后在部署到生产时启用它们。使用这种策略,您可能会在代码进入生产之前发现作业任务列表中的任何问题。但是,如果有什么问题通过了,数据库至少可以确保数据不会被损坏(尽管可能存在删除损坏的风险)。

还应注意的是,如果您禁用了级联删除,则在应用程序之外删除记录将变得麻烦,例如在MySQL Workbench之类的数据库工具中。

关于事务的话

由于Waterfall按批次执行删除操作,并在执行数据库任务时暂停,因此无法使用事务。据我所知,没有绕过这个问题的方法,因为任何使用事务的长运行任务都可能锁定数据库,从而抵消Waterfall带来的全部性能优势。

如果有人能想出一种有效的方法来使事务在这种配置下工作,我会非常乐意接受PR。

贡献

感谢您考虑为Waterfall做出贡献。您欢迎提交包含改进的PR,但是如果您所做的改进是实质性的,请务必包括测试或测试。

许可

MIT许可(MIT)。有关更多信息,请参阅许可文件