Doctrine/Eloquent的软删除(Soft Delete)实现:数据库设计与查询优化

Doctrine/Eloquent的软删除(Soft Delete)实现:数据库设计与查询优化

大家好,今天我们来深入探讨 Doctrine/Eloquent 框架下的软删除(Soft Delete)实现,包括数据库设计、代码实现、查询优化以及一些最佳实践。软删除是一种常见的数据管理策略,它允许我们在逻辑上删除数据,而不是物理删除,从而保留数据的历史信息和审计记录,避免误删导致的数据丢失,并支持数据恢复。

1. 软删除的概念与优势

软删除 (Soft Delete) 是一种数据删除方法,并非直接从数据库中物理删除记录,而是通过设置一个特定的标志位(通常是一个 deleted_at 字段)来标记该记录为已删除。

相比于硬删除 (Hard Delete),软删除具有以下优势:

  • 数据恢复: 软删除允许轻松恢复已删除的数据,只需将 deleted_at 字段设置为 NULL 即可。
  • 审计跟踪: 软删除保留了数据的历史记录,方便审计和分析。
  • 避免误删: 软删除避免了因误操作导致的数据永久丢失。
  • 数据一致性: 软删除可以维护关联数据的一致性,例如,订单数据可以保留已删除的商品信息。

2. 数据库设计

在数据库设计中,实现软删除的关键是添加一个用于标记删除状态的字段。通常,我们使用 deleted_at 字段,其数据类型为 TIMESTAMPDATETIME。当记录被删除时,deleted_at 字段会被设置为当前的日期和时间;当记录未被删除时,deleted_at 字段的值为 NULL。

示例:用户表 (users)

Column Type Nullable Default Comment
id BIGINT NO Primary Key
name VARCHAR(255) NO 用户名
email VARCHAR(255) NO 邮箱
password VARCHAR(255) NO 密码
created_at TIMESTAMP YES 创建时间
updated_at TIMESTAMP YES 更新时间
deleted_at TIMESTAMP YES NULL 删除时间

SQL 创建表语句:

CREATE TABLE users (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP NULL DEFAULT NULL,
    updated_at TIMESTAMP NULL DEFAULT NULL,
    deleted_at TIMESTAMP NULL DEFAULT NULL
);

3. Eloquent 模型实现

在 Laravel 的 Eloquent 模型中,我们可以使用 SoftDeletes trait 轻松实现软删除功能。

步骤:

  1. 引入 SoftDeletes trait: 在模型类中引入 IlluminateDatabaseEloquentSoftDeletes trait。
  2. 定义 $dates 属性: 在模型类中定义 $dates 属性,并将 deleted_at 添加到该属性中。这告诉 Eloquent 将 deleted_at 字段视为日期类型。

示例:User 模型

<?php

namespace AppModels;

use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentSoftDeletes;

class User extends Model
{
    use HasFactory, SoftDeletes;

    /**
     * The attributes that should be mutated to dates.
     *
     * @var array
     */
    protected $dates = ['deleted_at'];

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for arrays.
     *
     * @var array
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * The attributes that should be cast to native types.
     *
     * @var array
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];
}

使用方法:

  • 删除: 使用 delete() 方法执行软删除。

    $user = User::find(1);
    $user->delete(); // 软删除,设置 deleted_at 字段
  • 查询: 默认情况下,Eloquent 查询将排除软删除的记录。

    $users = User::all(); // 只返回 deleted_at 为 NULL 的记录
  • 查询包含软删除的记录: 使用 withTrashed() 方法可以查询包含软删除的记录。

    $users = User::withTrashed()->get(); // 返回所有记录,包括 deleted_at 不为 NULL 的记录
  • 仅查询软删除的记录: 使用 onlyTrashed() 方法可以仅查询软删除的记录。

    $users = User::onlyTrashed()->get(); // 只返回 deleted_at 不为 NULL 的记录
  • 恢复软删除的记录: 使用 restore() 方法可以恢复软删除的记录。

    $user = User::withTrashed()->find(1);
    $user->restore(); // 恢复软删除,设置 deleted_at 字段为 NULL
  • 永久删除记录 (Hard Delete): 使用 forceDelete() 方法可以永久删除记录。

    $user = User::withTrashed()->find(1);
    $user->forceDelete(); // 永久删除,从数据库中移除记录

4. 查询优化

在使用软删除时,我们需要注意查询优化,以避免性能问题。特别是当数据量较大时,不当的查询可能会导致性能瓶颈。

优化策略:

  1. 索引:deleted_at 字段上创建索引。这可以显著提高查询性能,特别是当我们需要查询未删除的记录时。

    CREATE INDEX idx_deleted_at ON users (deleted_at);
  2. 避免全表扫描: 尽量避免使用 withTrashed() 方法进行全表扫描。如果只需要查询部分软删除的记录,可以使用更精确的条件。例如,根据其他字段进行过滤后再使用 withTrashed()

  3. 使用 whereNull()whereNotNull(): 使用 whereNull('deleted_at')whereNotNull('deleted_at') 代替 where('deleted_at', '=', null)where('deleted_at', '!=', null)。前者通常性能更好,因为数据库优化器可以更好地利用索引。

    $users = User::whereNull('deleted_at')->get(); // 查找未删除的用户
    $trashedUsers = User::whereNotNull('deleted_at')->get(); // 查找已删除的用户
  4. 分页查询: 对于大量数据的查询,使用分页可以有效地减少单次查询的数据量,从而提高性能。

    $users = User::paginate(10); // 每页显示 10 个用户
  5. 使用 Eager Loading: 如果你的查询涉及到关联模型,使用 Eager Loading 可以避免 N+1 查询问题,从而提高性能。例如,查询用户及其相关的文章:

    $users = User::with('articles')->get(); // 预加载文章
  6. 缓存: 对于不经常变动的数据,可以使用缓存来减少数据库查询次数,从而提高性能。

    use IlluminateSupportFacadesCache;
    
    $users = Cache::remember('users', 60, function () {
        return User::all();
    });
  7. 数据库级别的视图 (Views): 对于复杂的查询,可以考虑创建数据库视图。视图可以预先计算好数据,并提供一个简单的接口供应用程序访问。

    CREATE VIEW active_users AS
    SELECT id, name, email
    FROM users
    WHERE deleted_at IS NULL;

    然后在 Laravel 中,你可以像访问普通表一样访问视图。

  8. 数据库分析工具: 使用数据库提供的分析工具(例如 MySQL 的 EXPLAIN 命令)来分析查询的执行计划,找出潜在的性能瓶颈。

    EXPLAIN SELECT * FROM users WHERE deleted_at IS NULL;

5. 全局 Scopes

Eloquent 的全局 Scopes 允许你为模型添加约束,这些约束会在每次查询模型时自动应用。我们可以使用全局 Scopes 来自动过滤掉软删除的记录。

步骤:

  1. 创建 Scope 类: 创建一个实现了 IlluminateDatabaseEloquentScope 接口的类。
  2. 实现 apply() 方法:apply() 方法中,添加查询约束。

示例:SoftDeletingScope

<?php

namespace AppScopes;

use IlluminateDatabaseEloquentBuilder;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentScope;

class SoftDeletingScope implements Scope
{
    /**
     * Apply the scope to a given Eloquent query builder.
     *
     * @param  IlluminateDatabaseEloquentBuilder  $builder
     * @param  IlluminateDatabaseEloquentModel  $model
     * @return void
     */
    public function apply(Builder $builder, Model $model)
    {
        $builder->whereNull($model->getQualifiedDeletedAtColumn());
    }
}

注册 Scope:

  1. 在模型中注册: 在模型的 boot() 方法中注册全局 Scope。

    <?php
    
    namespace AppModels;
    
    use IlluminateDatabaseEloquentFactoriesHasFactory;
    use IlluminateDatabaseEloquentModel;
    use IlluminateDatabaseEloquentSoftDeletes;
    use AppScopesSoftDeletingScope;
    
    class User extends Model
    {
        use HasFactory, SoftDeletes;
    
        // ...
    
        /**
         * The "booted" method of the model.
         *
         * @return void
         */
        protected static function booted()
        {
            static::addGlobalScope(new SoftDeletingScope);
        }
    }

移除 Scope:

如果你需要临时移除全局 Scope,可以使用 withoutGlobalScope() 方法。

$users = User::withoutGlobalScope(SoftDeletingScope::class)->get();

6. 关联关系处理

在使用软删除时,需要特别注意关联关系的处理。以下是一些常见的场景和处理方法:

  • 一对一关系: 如果一个模型与另一个模型存在一对一关系,并且你希望在查询父模型时自动排除已软删除的子模型,可以使用 whereDoesntHave() 方法。

    // 查询没有被软删除的用户资料的用户
    $users = User::whereDoesntHave('profile', function ($query) {
        $query->whereNotNull('deleted_at');
    })->get();
  • 一对多关系: 类似于一对一关系,可以使用 whereDoesntHave() 方法来排除已软删除的子模型。

    // 查询没有被软删除的文章的用户
    $users = User::whereDoesntHave('articles', function ($query) {
        $query->whereNotNull('deleted_at');
    })->get();
  • 多对多关系: 多对多关系的处理稍微复杂一些,因为涉及到中间表。你需要确保中间表也包含 deleted_at 字段,并且在查询时考虑这个字段。

    // 假设用户和角色是多对多关系,并且 pivot 表 (user_role) 也有 deleted_at 字段
    $users = User::whereDoesntHave('roles', function ($query) {
        $query->wherePivot('deleted_at', '!=', null);
    })->get();

    或者,你可以在定义关联关系时,使用 withPivot() 方法来指定中间表的 deleted_at 字段。

    public function roles()
    {
        return $this->belongsToMany(Role::class)->withPivot('deleted_at');
    }

    然后,在查询时可以使用 wherePivot() 方法。

    $users = User::whereDoesntHave('roles', function ($query) {
        $query->wherePivot('deleted_at', '!=', null);
    })->get();

7. 事件 (Events)

Eloquent 提供了丰富的事件机制,可以在模型创建、更新、删除等操作前后触发事件。我们可以利用这些事件来执行一些额外的操作,例如:

  • 记录删除日志: 在模型被软删除后,记录删除日志,包括删除的用户、删除时间等信息。

    <?php
    
    namespace AppModels;
    
    use IlluminateDatabaseEloquentFactoriesHasFactory;
    use IlluminateDatabaseEloquentModel;
    use IlluminateDatabaseEloquentSoftDeletes;
    
    class User extends Model
    {
        use HasFactory, SoftDeletes;
    
        // ...
    
        /**
         * The "booted" method of the model.
         *
         * @return void
         */
        protected static function booted()
        {
            parent::booted();
    
            static::deleted(function ($user) {
                // 记录删除日志
                Log::info('User deleted: ' . $user->id);
            });
        }
    }
  • 清理关联数据: 在模型被永久删除后,清理与其关联的数据。

    <?php
    
    namespace AppModels;
    
    use IlluminateDatabaseEloquentFactoriesHasFactory;
    use IlluminateDatabaseEloquentModel;
    use IlluminateDatabaseEloquentSoftDeletes;
    
    class User extends Model
    {
        use HasFactory, SoftDeletes;
    
        // ...
    
        /**
         * The "booted" method of the model.
         *
         * @return void
         */
        protected static function booted()
        {
            parent::booted();
    
            static::deleting(function ($user) {
                // 在永久删除用户之前,删除其相关的文章
                $user->articles()->delete(); // 软删除
                // 或者
                // $user->articles()->forceDelete(); // 永久删除
            });
        }
    }

8. 最佳实践

  • 统一 deleted_at 字段命名: 在所有需要软删除的模型中使用统一的 deleted_at 字段命名,方便维护和管理。
  • 使用全局 Scopes 自动过滤软删除的记录: 可以有效避免在每个查询中都手动添加 whereNull('deleted_at') 约束。
  • 谨慎使用 forceDelete() 方法: 永久删除操作不可逆,务必谨慎使用。建议在生产环境中禁用 forceDelete() 方法,或者添加额外的权限验证。
  • 定期清理软删除的数据: 如果软删除的数据量过大,可能会影响查询性能。建议定期清理软删除的数据,例如,将超过一定时间的软删除数据永久删除。
  • 考虑使用数据库触发器 (Triggers): 在某些情况下,可以使用数据库触发器来实现一些复杂的软删除逻辑,例如,自动更新关联表的数据。

代码示例总结

本文提供了多个代码示例,覆盖了软删除的各个方面,包括:

  • 模型定义: 展示了如何使用 SoftDeletes trait,以及如何定义 $dates 属性。
  • 基本操作: 演示了如何使用 delete(), restore(), withTrashed(), onlyTrashed(), forceDelete() 方法。
  • 查询优化: 介绍了如何使用 whereNull(), whereNotNull(), paginate(), with() 方法。
  • 全局 Scopes: 展示了如何创建和注册全局 Scopes。
  • 关联关系处理: 演示了如何使用 whereDoesntHave() 方法处理关联关系。
  • 事件: 介绍了如何使用事件来执行额外的操作。

关于软删除的思考

软删除是一个强大的工具,但并非适用于所有场景。在选择使用软删除时,需要仔细考虑其优缺点,并根据具体的业务需求进行权衡。比如:对于一些敏感数据,可能需要直接硬删除,以确保数据的安全性。另外,需要权衡软删除带来的数据冗余和查询效率的影响。

保证性能,合理应用

通过本文的讲解,相信大家对 Doctrine/Eloquent 的软删除实现有了更深入的了解。掌握了软删除的原理、实现方法和优化策略,可以更好地应用于实际项目中,提高数据管理的效率和安全性。请记住,在实际应用中,需要根据具体的业务场景和数据量,选择合适的策略,以保证性能和数据的完整性。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注