Ohhnews

分类导航

$ cd ..
foojay原文

在Java中实现软删除

#软删除#java#mongodb#数据恢复#ttl索引

目录
什么是软删除?
如何实现软删除?

什么是软删除?

通常,从数据库中删除文档后,该条目将永久消失,无法恢复或再次访问。

有时,数据需要在不实际从数据库中移除的情况下,对常规访问不可用。一个常见的例子是用户删除其在平台上的账户,但数据保留策略要求您在一定时间段内保留与该用户相关的所有数据。同时,平台上不能访问任何关于该用户的数据。软删除是对数据的一种处理方式,确保应用程序忽略该数据,但实际仍存储在数据库中。

如何实现软删除?

字段标记

实现软删除的一种方法是在集合中添加一个额外字段,用于跟踪文档是否应对应用程序可见。最简单的方法是实现一个布尔字段(例如命名为 isDeleted),在查询时排除已软删除的文档。虽然这能标识文档是否已被删除,但无法帮助满足数据保留期限的要求。为此,我们需要知道文档何时被删除。因此,更好的解决方案是改用日期字段,命名为 deletedAt。该字段对于每个活动文档设置为 null,而对于已删除的文档则保留删除时间戳。

归档集合

另一种方法是将数据移入归档集合。例如,如果您使用一个用户集合并需要实现软删除,可添加一个 user_deleted 集合,将已删除的文档移至该处。在这种情况下,仍可在文档中添加一个单独的日期字段,以追踪文档被删除的时间(或移动到归档集合的时间)。与标记删除文档相比,这种方法的优点是保持了主集合的轻量化,并且不会对查询增加额外开销。## 软删除的实现

Java 驱动

本指南中的代码片段基于 MongoDB Java 同步驱动 5.6 版本。这是 MongoDB 官方提供的最新版 Java 驱动,用于开发同步应用程序。

代码示例

作为本指南示例的基础,我们使用 MongoDB Atlas 提供的示例数据库之一——sample_mflix

标志方法

我们要做的,是将 users 集合的架构从当前形式:

$ cat
{
  "_id": {
    "$oid": "59b99dddcfa9a34dcd788604"
  },
  "name": "Thoros of Myr",
  "email": "paul_kaye@gameofthron.es",
  "password": "$2b$12$bkA1MM3UEwZ4N0VpCQY68eMY8HKTHWtk2xI2QnG4MuW5UWHlBrF8G"
}

改为包含一个删除标记的形式:

$ cat
{
  "_id": {
    "$oid": "59b99dddcfa9a34dcd788604"
  },
  "name": "Thoros of Myr",
  "email": "paul_kaye@gameofthron.es",
  "password": "$2b$12$bkA1MM3UEwZ4N0VpCQY68eMY8HKTHWtk2xI2QnG4MuW5UWHlBrF8G",
  "deletedAt": null
}

值得一提的是,你也可以选择不为活跃文档添加该字段。MongoDB 在查询 {"deletedAt": null} 时仍然会返回这些文档。然而,如果该字段缺失,字段上的索引将无法高效地返回所有活跃文档。因此,我们将主动为集合中的文档设置 null 值,随后再更新文档为正确的时间戳以实现软删除。但首先,我们将设置一个可复用的 mongoclient 对象,以利用 MongoDB 的连接池,而不是每次应用调用都创建新客户端。这将显著降低延迟并减少创建新连接的次数:

$ java
public final class MongoClientProvider {

    private static volatile MongoClient mongoClient;
    private static final Object LOCK = new Object();

    // 私有构造函数,防止实例化
    private MongoClientProvider() {}

    public static MongoClient getClient() {
        if (mongoClient == null) {
            synchronized (LOCK) {
                if (mongoClient == null) {
                    mongoClient = createClient();
                    validateConnection(mongoClient);
                    registerShutdownHook();
                }
            }
        }
        return mongoClient;
    }

    private static MongoClient createClient() {

        ConnectionString connString = new ConnectionString("<my connection string>");

        MongoClientSettings settings = MongoClientSettings.builder()
                .applyConnectionString(connString)
                .retryWrites(true)
                .build();

        return MongoClients.create(settings);
    }

    // 使用 ping 验证连接
    private static void validateConnection(MongoClient client) {
        try {
        client.getDatabase("admin")
              .runCommand(new Document("ping", 1));
        } catch (Exception e) {
            throw new IllegalStateException("Failed to connect to MongoDB", e);
    }

   // 确保 JVM 关闭时正确关闭 MongoClient
   private static void registerShutdownHook() {
      Runtime.getRuntime().addShutdownHook(new Thread(() -> {
         if (mongoClient != null) {
            try {
               mongoClient.close();
            } catch (Exception e) {
               System.err.println("Error closing MongoClient: " + e.getMessage());
            }
         }
      }));
   }

}
}

现在我们可以使用 getClient() 方法高效地调用数据库连接。接下来,我们开始初始化集合:

$ java
import com.mongodb.client.MongoDatabase;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.result.UpdateResult;
import org.bson.Document;
import static com.mongodb.client.model.Filters.*;
import static com.mongodb.client.model.Updates.*;

public class UpdateByFieldExample {

public static void main(String[] args) {

    try (MongoClient mongoClient = getClient()) {

        MongoDatabase database = mongoClient.getDatabase("sample_mflix");
        MongoCollection<Document> collection = database.getCollection("users");

        UpdateResult result = collection.updateMany(
Filters.empty(), // 空过滤器,应用于所有文档
Updates.set("deletedAt", null)
        );

        }
    }
}

这种方法会一次性更新整个集合。对于较大型的集合,建议分批执行此类操作,以避免一次性将全部数据加载到内存中,并更好地控制吞吐量,例如在需要维持常规工作负载的生产环境中。分批执行变更的方法如下:

$ java
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import com.mongodb.client.result.UpdateResult;

import org.bson.Document;
import org.bson.types.ObjectId;

import java.util.ArrayList;
import java.util.List;

import static com.mongodb.client.model.Filters.*;
import static com.mongodb.client.model.Updates.*;

public class BatchUpdateExample {

    public static void main(String[] args) {

        try (MongoClient mongoClient = getClient()) {

            MongoDatabase db = mongoClient.getDatabase("sample_mflix");
            MongoCollection<Document> collection = db.getCollection("users");

            int batchSize = 100;
            ObjectId lastId = null;

            while (true) {

                // 空过滤器应用于所有文档,附加上一批的最后 ID 以跳过已处理的文档
                Bson filter = lastId == null ? 
                Filters.empty() : Fitlers.gt("_id", lastId);
                }

                // 获取一批文档
                FindIterable<Document> docs = collection.find(filter)
                        .sort(new Document("_id", 1))
                        .limit(batchSize);


                // 将每个文档的 ID 放入 List
                List<ObjectId> ids = new ArrayList<>();
                for (Document doc : docs) {
                    ObjectId id = doc.getObjectId("_id");
                    ids.add(id);
                    lastId = id;
                }


                // 如果没有剩余文档,则跳出循环
                if (ids.isEmpty()) {
                    break;
                }

                // 对当前批次执行更新
                UpdateResult result = collection.updateMany(
                        in("_id", ids),
                        Updates.set("deletedAt", null)
                        )
                );
            }
        }
    }
}

现在集合已准备就绪,我们可以开始应用软删除逻辑。通过将 "deletedAt" 从 null 更改为当前时间戳来删除单个文档:

$ java
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import java.util.Date;

public static void softDeleteUser(String id) {

      try (MongoClient mongoClient = getClient()) {
            MongoDatabase db = mongoClient.getDatabase("sample_mflix");
            MongoCollection<Document> collection = db.getCollection("users");

    		collection.updateOne(
            		Filters.eq("_id", new org.bson.types.ObjectId(id)),
                    	Updates.set("deletedAt", new Date())
    		);
	}
}

现在删除功能已就绪,我们需要调整查询(在过滤条件中添加 deletedAt:null),以避免获取软删除的文档。

假设我们通常按姓名和邮箱查询用户,如下所示:

$ java
public static Document getUser(String name, String mail)) {
Try (MongoClient mongoClient = getClient()) {

   MongoDatabase db = client.getDatabase("sample_mflix");
   MongoCollection<Document> collection = database.getCollection("users");

   Bson filter = Filters.and(Filters.eq("name", name), Filters.eq("email", mail));

   // 检索与过滤器匹配的文档
   Document result = collection.find(filter).first();
   Return result;
}

Return Document emptyDoc = createEmptyDocument();
}

现在我们将扩展过滤条件,确保查询不会获取到软删除的文档:

$ java
public static Document getUserIfNoSoftDelete(String name, String mail)) {
Try (MongoClient mongoClient = getClient()) {

   MongoDatabase db = client.getDatabase("sample_mflix");
   MongoCollection<Document> collection = database.getCollection("users");


   Bson filter = Filters.and(Filters.eq("name", name), Filters.eq("email", mail), Filters.eq(deletedAt, null));

   // 检索与过滤器匹配的文档
   Document result = collection.find(filter).first();
   Return result;
}

Return Document emptyDoc = createEmptyDocument();

}

接下来需要对该字段建立索引,否则查询会变慢:

$ java
public static void createSoftDeleteIndex() {
Try (MongoClient mongoClient = getClient()) {

   MongoDatabase database = client.getDatabase("sample_mflix");
   MongoCollection<Document> collection = database.getCollection("users");

   // 创建复合索引
   String indexName = collection.createIndex(
      compoundIndex(
         ascending("email"),
         ascending("name"),
         ascending("deletedAt")
      ),
   new IndexOptions().name("idx_email_name_deletedAt")
   );
}
}

最后,我们希望能够恢复一个软删除的文档。原因可能是误删除或某种数据回滚。要实现这一点,只需将存储删除时间戳的 deletedAt 字段重新设为 null 即可:

$ java
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import java.util.Date;
public static void recoverDocument(String id) {
      try (MongoClient mongoClient = getClient()) {
            MongoDatabase db = mongoClient.getDatabase("sample_mflix");
            MongoCollection<Document> collection = db.getCollection("users");
    		collection.updateOne(
            		Filters.eq("_id", new org.bson.types.ObjectId(id)),
                    		Updates.set("deletedAt", null)
    		);
	}
}

至此,我们实现了一种删除方法,能够将被删除的文档保留在数据库中,以便后续恢复、审计或其他用途。

归档方法

标志方法的一种替代方案是通过将文档移动到一个归档集合来实现软删除。这样做的好处是主集合可以保持精简,从而加快访问速度并减小索引大小。

要实现这种方法,首先需要创建辅助集合:

$ java
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoDatabase;

public static void createCollectionExample() {

        try (MongoClient mongoClient = getClient()) {
            
            // 访问数据库(如果不存在则创建)
            MongoDatabase db = mongoClient.getDatabase("sample_mflix");

            // 创建新集合
            database.createCollection("users_archive");
        }
    }
}

现在我们可以将文档移动到该集合中,从而实现软删除:

$ java
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;

import org.bson.Document;
import org.bson.types.ObjectId;

import static com.mongodb.client.model.Filters.eq;

public static void softDeleteWithArchive() {

        try (MongoClient client = getClient()) {
            MongoDatabase db = client.getDatabase("sample_mflix");

            MongoCollection<Document> users = db.getCollection("users");
            MongoCollection<Document> archive = db.getCollection("users_archive");

            ObjectId userId = new ObjectId("64f123456789abcdef123456"); // 替换为真实 ID

            // 从用户集合中获取文档
            Document userDoc = users.find(eq("_id", userId)).first();

            if (userDoc != null) {
                // 将文档插入归档集合
                archive.insertOne(userDoc);

                // 从用户集合中删除文档
                users.deleteOne(eq("_id", userId));
            } 
        }
    }
}

使用此方法恢复文档时,只需同样将其移回即可。另外,也可以选择添加一个时间戳字段。这样就能记录文档何时被软删除。该信息可能很重要,例如当需要在满足特定保留期限后才能完全删除文档时。我们在后续讨论清理话题时会进一步说明。

级联到相关集合

使用此方法时,需要确保也将删除级联到相关集合。以我们的示例为例,来看看 comments 集合:

$ cat
{
  "_id": {
    "$oid": "5a9427648b0beebeb69579e7"
  },
  "name": "Mercedes Tyler",
  "email": "mercedes_tyler@fakegmail.com",
  "movie_id": {
    "$oid": "573a1390f29313caabcd4323"
  },
  "text": "Eius veritatis vero facilis quaerat fuga temporibus. Praesentium expedita sequi repellat id. Corporis minima enim ex. Provident fugit nisi dignissimos nulla nam ipsum aliquam.",
  "date": {
    "$date": "2002-08-18T04:56:07.000Z"
  }
}

该集合中的每条评论都由特定用户撰写,并通过也在用户集合中存在的 name 字段进行引用。当该用户被删除时,可能还需要删除所有相关的评论。问题在于:是否应该从数据库中真正移除这些评论,还是再次实现软删除以便恢复?这取决于具体的用例和保留策略。例如,假设你对用户集合使用软删除,因为某些法律要求你将该信息保留一段时间。在这种情况下,评论很可能可以进行硬删除,因为你再也不需要它们了。另一种场景是,你的应用程序提供了用户恢复功能,允许过去删除过账户的用户重新激活并恢复其所有信息。在这种情况下,你可能需要对所有相关数据实现软删除以支持该功能。对于此示例,我们假设相关文档将被硬删除。以下是实现该行为的一种方式:

$ java
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;
import com.mongodb.client.result.DeleteResult;

import org.bson.Document;

import static com.mongodb.client.model.Filters.eq;

public static void cascadeDeletion (String userName){
        try (MongoClient client = getClient()) {
            MongoDatabase db = client.getDatabase("sample_mflix");
            MongoCollection<Document> collection = db.getCollection("comments");

            // 删除所有由已删除用户发表的评论
            DeleteResult result = collection.deleteMany(eq("name", userName));
        }
    }
}

这可以包含在 softDelete() 函数内部,也可以单独调用。

使用 TTL 索引进行清理

在许多场景下,软删除只是临时解决方案。文档以软删除状态保留一段时间,然后才会从数据库中完全移除。一个常见用例是文档在指定时间不活跃后执行清理。例如,用户数据在账户删除后需要保留十二个月。软删除允许工程师在不影响活跃用户查询性能的情况下实现这一点。现在,为了清理超过保留期限的文档,我们可以利用 MongoDB 的 TTL 索引。这是一种特殊的单字段索引,可用于自动从数据库中删除文档。为此,我们将在 deletedAt 字段上创建一个索引,该字段在执行软删除时被设置为时间戳。

任何根本不包含该字段的文档都不会受到 TTL 索引的影响。

$ java
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;

import org.bson.Document;

import static com.mongodb.client.model.Indexes.ascending;
import com.mongodb.client.model.IndexOptions;

import java.util.concurrent.TimeUnit;

public static void createTTLIndex () {

        try (MongoClient client = getClient) {
            MongoDatabase db = client.getDatabase("sample_mflix");
            MongoCollection<Document> collection = db.getCollection("users");

            // TTL 索引:文档在 deletedAt 之后 1 年(365 天)过期
            IndexOptions options = new IndexOptions()
                    .expireAfter(365L, TimeUnit.DAYS);

            String indexName = collection.createIndex(ascending("deletedAt"), options);
        }
    }
}

软删除的优缺点

在介绍了在 MongoDB 部署中实现软删除的基本步骤之后,我们来总结一下这种方法的优缺点。

优点

软删除的主要好处是能够简单快速地恢复单个文档,而无需依赖备份——备份不仅耗时更多,也无法提供软删除所带来的细粒度。此外,它是在不影响数据库性能的前提下确保数据保留和合规性政策的绝佳方式。最后,通过添加更多元数据(例如执行删除的用户),软删除还可以用于其他用途。

缺点

这种方法的缺点在于需要额外的存储空间来存放文档,而在硬删除的情况下这些文档不会占用任何资源。此外,查询的复杂性增加了,因为每个请求都需要确保不获取已删除的结果。另一个问题是索引可能因为维护额外的条目而变得臃肿。最后,当级联操作没有得到严格控制时,还会增加产生不一致数据库状态的风险。## 总结

  1. 软删除是支持快速数据恢复和保留策略的优秀功能。
  2. 软删除主要有两种方法:标记和归档。
  3. 提供了使用 MongoDB Java 同步驱动程序在 Java 中实现软删除的示例代码片段。
  4. 使用软删除时,必须始终考虑级联操作以及最终的清理,例如通过 TTL 索引。
  5. 尽管软删除功能强大,但也存在一些缺点。

本文《在 Java 中实现软删除》首发于 foojay