前言
如果你使用 MongoDB 作为数据库的话,更改 MongoDB 的集合(collection)对你来说肯定是家常便饭。比如说,当业务发展时,我们需要添加新字段(field)到特定的集合中,或者从目标集合中删除现有字段,以支持更多的产品需求。这个操作看起来非常简单,只要往 Java 里面加个新的字段就行了。然而,它远比想象中复杂和危险。万一考虑不周,就有可能会触发线上事件,甚至导致服务中断。在这篇文章里,我们将会探讨哪些地方可能出错、如何安全地更改 schema,以及如何排查问题。本文假设你已经熟悉地掌握了 MongoDB 的基本概念,以及将 Jackson 用作你 Java 程序的序列化框架。
阅读本文后,你会明白:
- 添加新字段时的潜在风险
- 如何用默认值填充缺失的数据
- 如何写单元测试
- 如何迁移现有的文档
- 如何给最坏的情况的情况作准备:撤销代码变更,恢复旧版
- 如果事故发生了,如何使用 Mongo 语句来解决问题?
- 如何从这篇文章拓展出去?
这篇文章是用 MongoDB 4.2、Jackson 2.12、以及 Java 11 写的,但文章概念并不依赖这些新版本,应该对旧版本也有效。文章有点长,因为涉及到的知识点很多。希望大家不要嫌弃,读过以后应该会有新的收获!
潜在的风险
增加一个新的字段(field)时,有可能出现什么问题?
如果我们在 Java 类中添加了一个新字段而不更改 MongoDB 数据库中的现有文档,反序列化(deserialization)可能会出现严重的错误。这主要是由于 Java 类里面的字段声明和数据库实际存储文档的不一致导致的。这么说有点抽象。我们来看个例子吧,看看会有什么问题。比如我们在做一个电商网站,Java 中有一个代表订单的类,叫做OrderV1
。这个订单的第一个版本 V1 包含 3 个字段:MongoDB 中的 Object ID、客户 ID 以及此订单的金额。然后最近产品经理希望添加“取消订单”这个功能,因此我们需要一个新的字段isCanceled
来支持此这个新功能,它也成为了我们订单的 V2 版OrderV2
。此外,产品经理还希望添加一个操作员(operator)来跟踪处理订单的人。整个更改看起来非常简单,实现起来就是:
-public class OrderV1 {
+public class OrderV2 {
@JsonProperty("_id")
private final String id;
@JsonProperty("customerId")
private final String customerId;
@JsonProperty("amount")
private final double amount;
+ @JsonProperty("isCanceled")
+ private final boolean isCanceled;
+ @JsonProperty("operator")
+ private final String operator;
...
}
但你马上会看到,这里有一些重大风险。根本不像想象中那么简单!
风险一:NullPointerException 异常
在不改变 MongoDB 现有文档的情况下,在 Java 类中加入的新字段以后,已有文档的相应字段可能被设定为 null,比如字段“操作员(operator)”。这是因为这些 Mongo 文档不存在“操作员”这个字段。在 Java 中,那些被设置为 null 被引用的时候,就会触发 NullPointerException 异常。我们需要额外小心地处理这种情况:要么在 Java 代码中的处理,有么在 MongoDB 中处理。我们将在下文详细讨论这些技巧。
风险二:无法撤销代码变更
另一个风险是无法撤销(revert)代码变更。如果没有对 Jackson Object Mapper 或对 Java 类进行额外配置,那一旦将新版代码部署到生产线中,我们极有可能无法将其安全地回滚到上一个版本。因为一旦 Java 的代码被 revert,那些在新版本部署以后创建的 MongoDB 文档,它们的反序列化将完全失败并抛出“字段无法识别”的异常(UnrecognizedPropertyException):
“java.io.UncheckedIOException: com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field “isCanceled” (class io.mincong.mongodb.model_changes.OrderV1), not marked as ignorable (3 known properties: “amount”, “customerId”, “_id”]) at …
这是因为新文档中有字段”是否被取消(isCanceled)”,但旧订单类 OrderV1 不知道如何去反序列化它。试想一下在生产线上发生这样的事:我们发现部署的 V2 版本无法正常使用,想回到 V1 版本。结果撤销了代码,但服务器里面却不断地产生“字段无法识别”的异常。。。这是一起严重的事故,足以中断正常的服务使用!
现在,我们更好地了解了添加新字段的两个风险,让我们看看如何使用不同的方案来改善它们,避免事故的发生。
处理 null 值
为了防止 NullPointerException 异常的出现,我们可以通过提供默认值来应对 Java 中缺失的数据。有 4 种方法可以做到这一点:
- 使用 Java 的语言特性避免 null 值
- 在构造器中填充 null 值
- 在 getter 中填充 null 值
- 使用额外的 Jackson 模块填充 null 值
使用 Java 的语言特性避免 null 值
当类属性(class attribute)为原始类型(primitive type)时,Jackson 会为我们选择默认值。对于 boolean,它默认为 false;对于 int,它默认为 0;对于 double,它默认为 0.0 等。因此,我们可以依靠此特性来避免在 Java 应用程序中出现 null 的情况。例如,为了表示订单是否被取消,我们可以对该字段使用 boolean。当 Mongo 文档中不存在此字段时,Java 会默认为“isCanceled=false”。这意味着此订单是有效订单,而不是被取消的订单。
public class OrderV2 {
/**
* This is a new boolean field.
*
* <p>For existing documents which do not contain this field, the
* deserialization defaults to `false`.
*/
@JsonProperty("isCanceled")
private final boolean isCanceled;
...
}
另外,我想强调一下在选择形容词时要小心敬慎。我们要确保 false 对于缺失字段的文档具有正确的含义。例如,如果我们要用一个字段以一个物品的可见性,有两种选择:是“isHidden”还是“isVisible”。这时候应该选哪一个呢?我们也许应该选择形容词是“isHidden”,而不是“isVisible”。因为对于现有的 Mongo 文档,它们没有关于可见性的字段。在这种情况下:
- 当字段不存在时,“isHidden”默认为 false,也就是“可见”
- 当字段不存在时,“isVisible”默认为 false,也就是“不可见”。
通常情况下,我们可能更需要“默认可见”,而不是“默认隐藏”。所以“isHidden”是一个比“isVisible”更好的选择。
在构造器中填充 null 值
另一种方法是在 Java 类的构造器中处理 null 的问题。因为当反序列化(deserialization)发生时,Jackson 通过注解“@JsonCreator”找到需要使用的构造器,并用来创建 Java 实例。所以说,在构造器中填充 null 值也是处理的好时机。下面举个例子,一方面我们有个 Java 类:它的构造器含有一个处理可能为 null 的 operator 的 if 语句:
public class OrderV2 {
@JsonProperty("operator")
private final String operator;
...
@JsonCreator
public OrderV2(
@JsonProperty("_id") String id,
@JsonProperty("customerId") String customerId,
@JsonProperty("amount") double amount,
@JsonProperty("isCanceled") boolean isCancelled,
@JsonProperty("operator") String operator,
@JsonProperty("productIds") List<String> productIds) {
...
if (operator == null) {
this.operator = "support@example.com";
} else {
this.operator = operator;
}
}
...
}
另一方面,我们有一个存储在 MongoDB 里面、需要被反序列化成 Java 的 JSON 文档,它并不含有字段 operator:
{
"_id": "1",
"customerId": "Customer1",
"amount": 100.0
}
通过 IntelliJ 的 debug 我们可以看见,在反序列化的过程中,operator 这个字段被 Jackson 视为 null,传给 Java 的构造器。但是在构造器中,它被修改了,默认值为“support@example.com”。也就是说,null 被妥善地处理了:
在 getter 中填充 null 值
跟构造器相似的另一个方法是在 getter 中填充 null 值:
public class OrderV2 {
@JsonProperty("operator")
private final String operator;
...
public String getOperator() {
return operator == null ? "support@example.com" : operator;
}
}
Jackson Jdk8Module
还有一种解决方案是使用 Java 的“Optional”类,结合 Jackson 模块 Jdk8Module 来处理序列化和反序列化。你可以访问 GitHub 项目 FasterXML/jackson-modules-java8 或阅读 Baeldung 上的文章 “Using Optional with Jackson” 来了解具体的用法。
写单元测试
为了更好地模拟更改增删字段的影响,我们可以写一些单元测试来测试不同的行为。写这一个章节的目的并不是为了说服大家测试所有的行为、涵盖所有的情况,因为这是非常耗时的一件事情。我只是想通过这个章节,分享不同的测试技术角度,证明它们是可行的。至于具体测试哪个方面,大家可以根据自己的情况考虑。
测试序列化
我们可以写测试确保序列化的正确性。也就是说,一方面 Java 的实例可以被序列化成 MongoDB 里面的文档,然后另一方面它能够被反序列化成 Java 里面的实例,而且反序列化的结果等于原来的初始实例。用图表示就是:
Java MongoDB
--- ---
orignal (V2) -----> Mongo document
restored (V2) <-----
代码:
// Given
var result = orderCollection.insertOne(order1);
// When
var results = orderCollection.find(Filters.eq("customerId", "BigCorp"));
// Then
assertThat(results).containsExactly(order1);
测试向后兼容性
我们也可以写测试确保向后兼容性(Backward-Compatibility),证明使用新的 Java 类(新的代码版本)是可以向后兼容的、不会出现异常或者被错误地反序列化。这么做是为了证明已经存在与数据库的数据不会因为 Java 那边的变化产生兼容性问题。因为我们修改了 Java 类(增加了新的字段),但是我们并没有做数据迁移。这个测试起来有点难,因为 Java 的版本已经改变了,在 Java 里面无法再创建出旧版本的那种结构。为了模拟这种情况,我们可以直接造一个原始的(raw)BSON 文档,嵌入数据库,然后再进行反序列化:
Document.parse("{ \"_id\": \"1\", \"customerId\": \"Customer1\", \"amount\": 100.0 }");
把上面的话画成图来表示的话,应该是下面这样。我们先在测试中创建一个 BSON 文档并放入数据库,然后在测试中把文档反序列化,证明反序列化的结果是我们期待的样子。它跟“测试序列化”不同的点在于:上文的 BSON 文档是由当前版本的 Java 代码创建的,这里的 BSON 文档是由旧版本的 Java 代码创建的(模拟已经存在数据库的数据)。目的是为了测试向后兼容性。
Java MongoDB
--- ---
BSON (V1) -----> Mongo document
restored (V2) <-----
你可以看到在上面 raw 文档中,我们没有加入新的字段isCanceled
,这样等于模仿了在 schema 改变之前,数据库中数据的模样。这样一来,我们就可以测试反序列化的正确性,保证反序列化的结果是我们期待的那样。
测试回滚(rollback)
你也可以测试回滚到上一个代码版本是安全的。也就是说,把一个新版本的代码序列化到 Mongo 数据库里面以后,用旧版本的代码去反序列化也能够正确地实现。画个图来表示就是:
Java MongoDB
--- ---
original (V2) -----> Mongo document
restored (V1) <-----
这样做的目的主要是为了证明:当新版本被部署到生产线以后发生异常,我们至少可以安全地撤销这个变更,回到上一个版本。因为我们在上文中提到,撤销有可能导致“字段无法识别”的异常(UnrecognizedPropertyException)。
迁移已有文档
上文提到的避免 NullPointerException 主要是通过在 Java 代码中提供默认值实现的。除了提供默认值外,还有没有别的做法呢?另一种做法就是迁移数据。我们可以迁移 MongoDB 中的已存在文档来保证序列化的正确性。在执行 Mongo 语句之前,请考虑:
- 是否需要备份?理想情况下,备份是定期自动执行的。如果没有自动备份,那考虑使用导出工具 mongoexport 来备份相关文档。
- 在生产线(production)实施迁移之前,有没有在本地或者 staging 环境中测试过要执行的语句?
- 在执行之前,有没有让至少一位同事去核对语句的正确性?
- 有没有在聊天工具中留下点记录,例如在 Slack 或 Microsoft Team,以跟踪操作?
- 有没有在批量修改多个文档之前,先试试修改一个文档?
现在,回到 Mongo 语句本身。这可以很简单:
db.orders.update(
{ isCanceled: { $exists: false } }, // 1
{ $set: { isCanceled: false } }, // 2
{ multi: true } // 3
)
在这个语句里面:
- 我们找出集合“订单”(orders)中不包含字段“isCanceled”的文档。
- 然后,对于这些文档,我们设置字段值为“false”。
- 默认情况下,修改语句仅更新单个文档。我们将其设置为更新多个选择—所有匹配的文档(不含有字段“isCanceled”)都会被修改。注意,这里最好执行两次更新查询:第一次修改一个文档来测试更新语句是否有效(使用选项
{ multi: false }
)。然后第二次使用选项{ multi: true }
,以更新与选择匹配的所有文档。这样的做法可以降低破坏整个集合的风险。
更新结果会显示所涉及的文档数量:与查询匹配的文档数量、更新或插入的文档数量以及修改的文档数量。
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })
准备回滚
在上文“潜在的风险”中,我们提到,回滚到 Java 程序的上一个版本可能是不可能的。回滚可能导致反序列化失败:
“java.io.UncheckedIOException: com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field “isCanceled” (class io.mincong.mongodb.model_changes.OrderV1), not marked as ignorable (3 known properties: “amount”, “customerId”, “_id”]) at [Source: (String)”{“_id”: “2”, “customerId”: “Customer2”, “amount”: 200.0, “isCanceled”: true, “operator”: “emea@example.com”, “productIds”: [“A”, “B”, “C”]}”; line: 1, column: 77] (through reference chain: io.mincong.mongodb.model_changes.OrderV1[“isCanceled”])”
这是因为新文档有字段”isCanceled”,但旧值类订单不知道如何去反序列化它!下面,我们将看到如何在 Jackson 中正确处理这个“字段无法识别”的错误。
全局处理
我们可以通过 Object Mapper 来对未知字段进行全局处理,让 Jackson 在面对所有的类都使用一样的处理方式。这个特性是”FAIL_ON_UNKNOWN_PROPERTIES”。我们可以让它不要抛出异常:
objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
这样处理的话,所有通过这个 object mapper 反序列的 JSON 都不会抛出异常。
局部处理
我们也可以通过注解(annotation)来对未知字段进行局部处理,让 Jackson 在面对特定的类使用这个处理方式。
@JsonIgnoreProperties(ignoreUnknown = true)
public class OrderV1 { ... }
这样处理的话,所有被反序列成 OrderV1 的 JSON 都不会抛出异常。与全局配置相比,局部设置允许让我们对不同 Java 类的行为有更精细的控制,但它也容易使程序员忘记要添加此注释。这样做也可能导致不同类的反序列化行为不一致的问题。
无论我们选择哪一种配置,全局或者局部,一旦配置好了,那么回滚就应该是安全的了!没有人希望自己的代码需要被回滚,但是为回滚的安全性做好准备,总是一件能让所有人感到安心的事情。
常用的 Mongo 语句
在前几节中,我们主要关注如何保证向后兼容的问题,避免发生线上事故。但是,如果生产线已经出事故了呢?毕竟不是所有人都能意识到他的代码变动可能引发线上事故。因此,学习一些基本的 Mongo 语句,为最坏的情况做好准备也是一件好事。也就是,出事故时如何快速修复。以下是我为大家准备的一些语句。
> db.orders.count()
2
计算订单的数量。看看有多少文档可能受牵连,以及出现问题时可能产生的影响。
> db.orders.find({ isCanceled: { $exists: false } }).limit(10).pretty()
{ "_id" : "1", "customerId" : "Customer1", "amount" : 100 }
找出前 10 个不含有新字段“isCanceled”的文档并以 pretty 格式显示。在实际更新之前文档或之后检查 JSON 的正确性。
> db.orders.update(
{ isCanceled: { $exists: true } },
{ $unset: { isCanceled: "" } }
{ multi: true }
)
对于含有字段“isCanceled”的所有文档删除字段。主要用来回滚。当 Java 代码被回滚到以前的版本,但旧版代码缺乏对 Jackson 的配置的时候导致反序列化失败的时候,可以快速删除新添加的字段。
其他场景
在上面的章节中,我们主要讨论了在向 MongoDB 添加新字段时发生的情况。但是有没有其他场景呢?
- 另一个常见情况是删除字段。删除字段可能也有问题,因为 Java 类可能不能正确地处理未知的字段。这正是我们在”准备回滚”部分讨论的内容。
- 另一种可能的情况是更改现有字段的类型。比如说把 int 改成 string。我建议避免这样做,我们完全可以使用其他名称创建新字段来避免序列化的错误。
- 在 Java 的 enum 中重命名或删除一个项。重命名 Java 类里面的 enum 是没问题的,但请确保 JSON 属性(JsonProperty)的命名不会随之改变。例如,通过将 enum 的一个项从 FOO 重命名为 BAR,默认的序列化也将从“FOO”更改为“BAR”,这可能产生严重的错误。删除一个项也是很危险的。在执行此操作之前,请确保此项不存在于 staging 和 production 任何数据库中。
上面只是我想到的一些场景,肯定也有一些别的场景是我没有想到的。欢迎大家留言讨论你们的观点。
拓展
如何从这篇文章拓展出去?
- 本文假设你使用 Jackson Databind 来处理 Java 与 MongoDB 之间的序列化和反序列化。如果你没有使用它,并希望尝试一下,看看 StackOverflow 的这个问题:Is there any way for creating Mongo codecs automatically?。我的代码主要是受到 Kevin Day 答案写的。
- 想要了解更多关于 MongoDB 操作的信息,如
$set
、$unset
,请访问 MongoDB 官方文档 “Update Operators”。 - 要了解有关数据库工具 mongodump 的更多信息,请访问 MongoDB 文档 mongodump。
你还可以在我的 GitHub 项目 mincong-h/java-examples 下找到本文的源代码。
结论
在本文中,我们看到了在 Java 应用程序中添加新字段时的对于 MongoDB 产生的潜在风险(NullPointerException 和无法安全回滚的问题)、填充 null 值的不同技术、如何编写单位测试、如何迁移现有文档、如何通过配置 Object Mapper 或添加 Java 注解(annotation)来正确处理未知字段为准备回滚作准备,以及一些有用的 MongoDB 语句,以帮助大家在出现事故时快速排查和修复问题。最后,我们还简要讨论了其他的场景,并且分享了一些让大家拓展出去的资源。希望这篇文章能够给你带来一些思考,让你的系统变得更加稳健可靠。谢谢大家!
参考文献
- MongoDB, “MongoDB Documentation”, 2021. https://docs.mongodb.com/
- Tatu Saloranta et al., “Jackson Databind”, 2021. https://github.com/FasterXML/jackson-databind