caneara / 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
如果您要删除一个'用户',这将触发级联删除该用户的所有'帖子',然后删除用户'帖子'积累的所有'赞'。同样,如果您只是删除一个'帖子',它不会删除'用户',但它将导致级联删除'帖子'积累的所有'赞'。因此,我们必须按照以下顺序执行删除任务
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)。有关更多信息,请参阅许可文件。