MongoDB 基础系列七:数据建模之特殊情况三,monetary data

前言

此篇博文是 Mongdb 基础系列之一;主要介绍 MongoDB 的数据建模相关内容;

本文为作者的原创作品,转载需注明出处;

Model Monetary Data

简介

The binary-based floating-point arithmetic used by many modern systems (i.e., float, double) is unable to represent exact decimal fractions and requires some degree of approximation making it unsuitable for monetary arithmetic. This constraint is an important consideration when modeling monetary data.

当代所使用的 float 和 double 并不能完全精确的表示小数而是通过一种近似的方式来表示,正是因为这种近似的表示使得 float 和 double 并不适合用来进行货币运算( monetary arithmetic );这个限制是 MongoDB 在对 monetary data 进行建模的一个重要参考因素;

MongoDB 使用 numeric 和 non-numeric 两种不同的针对 monetary data 的建模方式;

Numeric Model

如果你需要使用 database 来进行精确的,数学上的有效匹配或者是需要在服务器端进行数学运算(比如 $inc, $mul 或者是 aggregation framework arithmetic ),那么 使用 Numeric Model 来对 monetary data 进行建模;

通过下面的类型来进行 Numeric Model 建模

  • 使用 Decimal BSON 类型,它是基于十进制浮点格式,能够提供精确的精度;它使用在 MongoDB 3.4 或者之后的版本中;
  • 使用 Scale Factor 通过乘以 10 的幂将 monetary value 转换为 64 为整数( long BSON type);

Non-Numeric Model

如果不需要在服务器端对 monetary data 进行运算又或者服务器端使用近似值的方式是可以接受的,那么,建议使用 Non-Numeric Model 来对 monetary data 进行建模;

使用如下的方式来进行 Non-Numeric Model 建模

  • 使用两个字段来表示 monetary value:其中一个字段使用 string 来精确的存储 monetary value,而另外一个字段则使用基于二进制的浮点数( binary-based floating-point ),也就是 double BSON 类型,来近似的表示该值;

Numberic Model

Using a Scale Factor

注意,如果你当前使用的是 MongoDB 3.4 或者更高的版本,使用 decmial 类型来取代 Scale Factor method 来进行建模;

如何使用 Scale Factor 的方式来对 monetary data 进行建模,

  • 首先确定数值( monetary value )的精度,既是精确到多少位小数;比如要求精度是 0.1
  • 然后,将 monetary value 通过乘以 10 将其转换为一个十进制的数值;
  • 最后,将转换后的十进制数值进行存储即可

比如,我们要求的精度是千分之一,既是 0.001 的精确度,那么如果我们有 0.99 这样一个 monetary data,因此进行转换以后所得到的数值如下,

1
{ price: 9990, currency: "USD" }

我的总结

这种方式简单粗暴,用一个整型数值来变相的表示小数的方式;这样做的好处自然不言而喻,不丢失任何小数位的精度,并且可以按照十进制的方式来计算小数;

Using the Decimal BSON Type

从 MongoDB 3.4 和之后开始使用,

decimal BSON Type 使用 IEEE 754 decmial128 格式,该格式是基于十进制的浮点格式;不像 double BSON type 使用的是基于二进制的浮点格式,decimal128 处理 monetary data 的时候是提供的精确的精度而非近似的精度;

mongo shell 通过 NumberDecimal() 构造器来对decimal数值进行赋值和查询操作,下面这个例子向 gasprices collection 添加了一个有关 gas price 的 document,

1
db.gasprices.insert{ "_id" : 1, "date" : ISODate(), "price" : NumberDecimal("2.099"), "station" : "Quikstop", "grade" : "regular" }

通过下面这个查询来进行匹配,

1
db.gasprices.find( { price: NumberDecimal("2.099") } )

查看 NumberDecimal 获得更多相关信息,

Converting Values to Decimal

可以通过 One-Time Collection Transformation 或者 [modifying application logic] 的方式将 collection 的 values 转换为 decimal 类型;

One-Time Collection Transformation

通过对某个 collection 中的所有的 documents 进行迭代,然后依次将相关的 monetary value 转换为 decimal type,然后再批量的写回 documents;

注意,强烈不建议将旧的字段删掉以后,然后再新建相关的 decimal 字段;

Scale Factor Transformation

假设我们有如下的 monetary data 经过 Scale Factor 转换过后的结构,

1
2
3
4
5
{ "_id" : 1, "description" : "T-Shirt", "size" : "M", "price" : NumberLong("1999") },
{ "_id" : 2, "description" : "Jeans", "size" : "36", "price" : NumberLong("3999") },
{ "_id" : 3, "description" : "Shorts", "size" : "32", "price" : NumberLong("2999") },
{ "_id" : 4, "description" : "Cool T-Shirt", "size" : "L", "price" : NumberLong("2495") },
{ "_id" : 5, "description" : "Designer Jeans", "size" : "30", "price" : NumberLong("8000") }

通过使用 $multiply 操作,可以将上述的 price 字段的 long 型数值通过乘以 NumberDecimal(“0.01”) 转换为对应的 decimal 类型的数值;下面这个例子演示了如何通过 $addFields 方式将 price 字段转后以后的 decimal 存储到一个新的字段 priceDec 中,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
db.clothes.aggregate(
[
{ $match: { price: { $type: "long" }, priceDec: { $exists: 0 } } },
{
$addFields: {
priceDec: {
$multiply: [ "$price", NumberDecimal( "0.01" ) ]
}
}
}
]
).forEach( ( function( doc ) {
db.clothes.save( doc );
} ) )

简单分析一下上述代码的逻辑,首先通过 $match stage,匹配需要进行转换的 documents,找到以后,通过 $addFields stage 首先创建一个新的字段 priceDec,然后将转换后的值赋值给 priceDec;可以通过 db.clothes.find() 来验证该转换的结果;

1
2
3
4
5
{ "_id" : 1, "description" : "T-Shirt", "size" : "M", "price" : NumberLong(1999), "priceDec" : NumberDecimal("19.99") }
{ "_id" : 2, "description" : "Jeans", "size" : "36", "price" : NumberLong(3999), "priceDec" : NumberDecimal("39.99") }
{ "_id" : 3, "description" : "Shorts", "size" : "32", "price" : NumberLong(2999), "priceDec" : NumberDecimal("29.99") }
{ "_id" : 4, "description" : "Cool T-Shirt", "size" : "L", "price" : NumberLong(2495), "priceDec" : NumberDecimal("24.95") }
{ "_id" : 5, "description" : "Designer Jeans", "size" : "30", "price" : NumberLong(8000), "priceDec" : NumberDecimal("80.00") }

如果你并不想像上面那样用一个新的字段来存储转换后的 decmial,那么可以直接对原有字段 price 进行转换后,使用转换以后的值来覆盖原有的字段 price,使用 update() 操作,如下,

1
2
3
4
5
db.clothes.update(
{ price: { $type: "long" } },
{ $mul: { price: NumberDecimal( "0.01" ) } },
{ multi: 1 }
)

update() 方法会首先检测 price 字段是否存在并且该字段的值必须是一个 long 型数值,然后将 long 转换为 decimal,然后将该转换后的值覆盖原有的 price 字段值;可以通过下面的查询 db.clothes.find() 来进行检测,

1
2
3
4
5
{ "_id" : 1, "description" : "T-Shirt", "size" : "M", "price" : NumberDecimal("19.99") }
{ "_id" : 2, "description" : "Jeans", "size" : "36", "price" : NumberDecimal("39.99") }
{ "_id" : 3, "description" : "Shorts", "size" : "32", "price" : NumberDecimal("29.99") }
{ "_id" : 4, "description" : "Cool T-Shirt", "size" : "L", "price" : NumberDecimal("24.95") }
{ "_id" : 5, "description" : "Designer Jeans", "size" : "30", "price" : NumberDecimal("80.00") }
Non-Numeric Transformation

下面这个例子使用 [non-numeric] 的模式,将 monetary value 以 strings 的形式进行存储,如下,

1
2
3
4
5
{ "_id" : 1, "description" : "T-Shirt", "size" : "M", "price" : "19.99" }
{ "_id" : 2, "description" : "Jeans", "size" : "36", "price" : "39.99" }
{ "_id" : 3, "description" : "Shorts", "size" : "32", "price" : "29.99" }
{ "_id" : 4, "description" : "Cool T-Shirt", "size" : "L", "price" : "24.95" }
{ "_id" : 5, "description" : "Designer Jeans", "size" : "30", "price" : "80.00" }

可以通过下面的方式对其进行转换,转换为 decmial,

1
2
3
4
5
db.clothes.find( { $and : [ { price: { $exists: true } }, { price: { $type: "string" } } ] } )
.forEach( function( doc ) {
doc.priceDec = NumberDecimal( doc.price );
db.clothes.save( doc );
} );

简要的分析一下上述代码的逻辑,通过两个方法,find()forEach() 来分别进行处理,首先,通过 find() 查询到所有符合条件的 documents,既 price 字段必须存在且类型必须是 string,然后依次输出结果;然后,forEach() 方法遍历由 find() 输出的结果进行依次转换,将转换后的结果存储到一个的字段 priceDec 中;可以通过命令 db.clothes.find() 来进行检测,

1
2
3
4
5
{ "_id" : 1, "description" : "T-Shirt", "size" : "M", "price" : "19.99", "priceDec" : NumberDecimal("19.99") }
{ "_id" : 2, "description" : "Jeans", "size" : "36", "price" : "39.99", "priceDec" : NumberDecimal("39.99") }
{ "_id" : 3, "description" : "Shorts", "size" : "32", "price" : "29.99", "priceDec" : NumberDecimal("29.99") }
{ "_id" : 4, "description" : "Cool T-Shirt", "size" : "L", "price" : "24.95", "priceDec" : NumberDecimal("24.95") }
{ "_id" : 5, "description" : "Designer Jeans", "size" : "30", "price" : "80.00", "priceDec" : NumberDecimal("80.00") }

Non-Numeric Model

通常,使用 non-numberic model 来对 monetary data 进行建模的话,使用两个字段来进行存储,

  1. 第一个字段,将 monetary data 用 non-numberic 的数据类型进行存储,比如 BinData 或者 String;通常这个值是经过四舍五入的;
  2. 第二个字段,使用一个双精度浮点( double-precision floating point )数来存储其真实值,当然是接近于真实值;

通过下面这个例子来更进一步的认识,

1
2
3
4
{
price: { display: "9.99", approx: 9.9900000000000002, currency: "USD" },
fee: { display: "0.25", approx: 0.2499999999999999, currency: "USD" }
}

注意,USD 9.99 只是显示值,是一个近似值,approx 字段存储的是真实值;

References

https://docs.mongodb.com/manual/applications/data-models-applications/