mattkingshott / waterfall
Requires
- php: ^8.0
Requires (Dev)
- orchestra/testbench: ^6.0
- phpunit/phpunit: ^9.0
README
此包允许Laravel应用程序以分批的方式执行数据库级联删除操作。这种方法的主要优点是,在操作大型应用程序(例如分析平台)时,它可以避免因大量记录删除任务而压倒数据库。
这是为谁准备的?
如果您正在构建一个小型应用程序,或者您的数据库不太可能出现超过几千条记录的级联删除,那么您可能不需要这个包。只需像平时一样在迁移中强制执行级联删除即可。
如果您的数据库包含或将要包含数十万、数百万甚至数十亿条记录,并且删除一条记录将导致涉及相似数量记录的级联,那么这很可能会压倒您的数据库。在这种情况下,Waterfall可能是一个不错的选择。
如果您使用的是所谓的“NewSQL”平台,该平台不支持级联删除(例如,由于缺少外键约束),那么此包也是一个不错的选择。
它是如何工作的?
该过程相对简单。您定义一个作业,例如 DeleteUserJob
,它扩展了Waterfall自己的 Waterfall\Jobs\Job
。在此作业中,您配置需要执行的所有级联任务。当您准备好删除一个“用户”记录时,您将 DeleteUserJob
分发并为其提供“用户”的ID。
然后Waterfall将执行以下任务
- 软删除主记录(用户)。
- 迭代级联任务。
- 删除一批相关记录(1000)。
- 如果有更多记录可用,则短暂休眠,然后分发另一个作业以重复(i)。
- 如果没有更多记录可用,则短暂休眠,然后分发另一个作业以继续2。
- 当所有任务都完成后,硬删除主记录(用户)。
安装
使用Composer引入包
composer require caneara/waterfall
升级
第2版引入了一种完全不同的任务配置方式。在第1版中,任务是通过一系列在 create
工厂方法上的参数配置的,但是随着钩子的添加,这变得相当繁琐。
因此,将 Task
类重构为提供一系列可以串联起来构建任务的setter方法。建议在升级时重新阅读所有README。
配置
Waterfall包含一个配置文件,允许您
- 设置用于级联删除作业的队列名称(默认为'deletion')。
- 设置每查询要删除的批大小/记录数(默认为1000)。
- 设置每批之间给予数据库的休息时间(以秒为单位,默认为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
属性。这应设置为要删除的主记录的模型类型。在本例中,我们正在删除用户,因此 $type
是 User::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 将使用其配置文件中定义的批量大小和休息时间(您可以在需要时发布和修改)。但是,如果您需要,您可以为单个任务覆盖这些值。如果您正在使用钩子(我们将在稍后探讨),这特别有用。
要更改批量大小或休息时间,分别调用 batch
或 rest
方法,例如:
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 通过包含钩子功能允许这样做。
您可以在批量删除前、批量删除后或同时进行任务钩子操作。为此,请调用 before
或 after
方法,并提供一个接受 $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
查询,这要高效得多。
如果您想使用钩子,那么有一些重要的事情需要注意
- 对于大型记录,您可能会耗尽内存。因此,请确保设置较小的批次大小。
- 如果您有大型记录,但只需要 ID 或列的子集,则可以考虑使用
query
方法只选择所需的字段。这将减少对数据库的压力,并降低内存耗尽的风险。 - 钩子需要额外的处理时间,因此请确保为您的作业提供足够的超时时间。
如何对任务进行排序
为了防止级联删除操作发生,您必须按相反顺序执行删除任务。为了更好地说明这一点,考虑以下示例数据库
users -> posts -> likes
如果您删除一个 'user',则会触发级联删除该用户的所有 'posts',然后删除该用户 'posts' 累积的所有 'likes'。同样,如果您只删除一个 'post',则不会删除 'user',但会引发级联删除 'post' 累积的所有 '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(Pull Request,即代码提交请求)。
贡献
感谢您考虑为Waterfall做出贡献。您可以提交包含改进的PR,但如果它们是实质性的,请务必包括相应的测试或测试。
许可协议
MIT许可协议(MIT)。有关更多信息,请参阅许可文件。