Python操作MongoDB的时区问题

最近使用Python写一段缓存用户登陆信息的一段代码,使用MongoDB保存token及用户信息,同时设置过期时间为当前时间1小时后,代码如下:

db['user'].insert_one(
    {'token': token, 'info': user, 'expire_at': datetime.fromtimestamp(time.time() + 60 * 60 * 1)}
)

结果发现过了预期的时间很久后,数据依然存在。

rs0:PRIMARY> db.user.getIndexes()
[
    {
        "v" : 2,
        "key" : {
            "_id" : 1
        },
        "name" : "_id_",
        "ns" : "sign.user"
    },
    {
        "v" : 2,
        "key" : {
            "expire_at" : 1
        },
        "name" : "expire_at_1",
        "ns" : "sign.user",
        "expireAfterSeconds" : 0
    }
]
rs0:PRIMARY> db.user.find({"_id" : ObjectId("5e0cfc630f37131c94412622")}, {expire_at: 1}).pretty()
{
    "_id" : ObjectId("5e0cfc630f37131c94412622"),
    "expire_at" : ISODate("2020-01-02T10:10:22.160Z")
}

一开始有点纳闷,仔细一看ISODate中有个"Z",大概知道怎么回事了:时区在作怪!

咱们是东8时,比标准时间快了8小时,也就是说我这里设置的是1小时后过期,实际上存储的时间是标准时间的9小时后...

于是想到下面两种方案来处理这个问题。

方案1:使用pymongo保存时间时,指定时区

首先尝试用几种不同的方法插入几条测试数据

db['user'].insert_one({'test': datetime.now()})
db['user'].insert_one({'test': datetime.utcnow()})
db['user'].insert_one({'test': datetime.fromtimestamp(time.time())})
db['user'].insert_one({'test': datetime.utcfromtimestamp(time.time())})

python环境下查询结果如下

{'_id': ObjectId('5e0d7369db0af443d23152e5'), 'test': datetime.datetime(2020, 1, 2, 12, 36, 57, 550000)}
{'_id': ObjectId('5e0d737bdb0af443d23152e6'), 'test': datetime.datetime(2020, 1, 2, 4, 37, 15, 745000)}
{'_id': ObjectId('5e0d73e1db0af443d23152e7'), 'test': datetime.datetime(2020, 1, 2, 12, 38, 57, 925000)}
{'_id': ObjectId('5e0d73eadb0af443d23152e8'), 'test': datetime.datetime(2020, 1, 2, 4, 39, 6, 430000)}

mongo客户端环境下查询结果如下

{ "_id" : ObjectId("5e0d7369db0af443d23152e5"), "test" : ISODate("2020-01-02T12:36:57.550Z") }
{ "_id" : ObjectId("5e0d737bdb0af443d23152e6"), "test" : ISODate("2020-01-02T04:37:15.745Z") }
{ "_id" : ObjectId("5e0d73e1db0af443d23152e7"), "test" : ISODate("2020-01-02T12:38:57.925Z") }
{ "_id" : ObjectId("5e0d73eadb0af443d23152e8"), "test" : ISODate("2020-01-02T04:39:06.430Z") }

由此可见,如果直接使用pymongo及datetime保存时间字段是,如果不设置时区,就会与标准时间产生偏差。

通过查阅pymongo的帮助文档发现,MongoClient这个类的构造函数中有一个参数tz_aware,也正如其字面含义(知道、察觉时区)。

使用MongoClient连接时将这个参数设为True,查询结果如下

{'_id': ObjectId('5e0d7369db0af443d23152e5'), 'test': datetime.datetime(2020, 1, 2, 12, 36, 57, 550000, tzinfo=<bson.tz_util.FixedOffset object at 0x7fa4b0a8fac0>)}
{'_id': ObjectId('5e0d737bdb0af443d23152e6'), 'test': datetime.datetime(2020, 1, 2, 4, 37, 15, 745000, tzinfo=<bson.tz_util.FixedOffset object at 0x7fa4b0a8fac0>)}
{'_id': ObjectId('5e0d73e1db0af443d23152e7'), 'test': datetime.datetime(2020, 1, 2, 12, 38, 57, 925000, tzinfo=<bson.tz_util.FixedOffset object at 0x7fa4b0a8fac0>)}
{'_id': ObjectId('5e0d73eadb0af443d23152e8'), 'test': datetime.datetime(2020, 1, 2, 4, 39, 6, 430000, tzinfo=<bson.tz_util.FixedOffset object at 0x7fa4b0a8fac0>)}

这样能够知道查询到的时间使用的是什么时区,使用utcnowutcfromtimestamp这两个函数保存的时间查询后可以根据时区得到真实的时间,不过如果保存时就已经发生了错误,就没有办法了。

除此以外,有没有方法来控制保存时间时采用的时区呢?除了上文提到的utcnowutcfromtimestamp还有其它办法吗?继续查阅pymongo文档,其中提到了pytz这个库。

from datetime import datetime
import pytz

db['user'].insert_one(
    {'test': pytz.timezone('Asia/Shanghai').localize(datatime.now())}
)

实际上,datetime也支持一个默认参数tz,可以传入tzinfo类型的值来指定时区。

db['user'].insert_one(
    {
        'token': ticket, 
        'info': user, 
        'expire_at': datetime.fromtimestamp(time.time() + 60 * 60 * 1, tz=pytz.timezone('Asia/Shanghai'))
    }
)

这几种办法保存时间,采用的都是将时间先转化成标准时间,再进行保存。所以读取时也需要将标准时间转换成目标时区的时间。

from bson.codec_options import CodecOptions

collection = db.user.with_options(
    codec_options=CodecOptions(tz_aware=True, tzinfo=pytz.timezone('Asia/Shanghai'))
)
result = collection.find()

获得带时区的datetime后,astimezone函数可以进行时区转换

import datetime
import pytz


if __name__ == '__main__':
    y = datetime.datetime(2020, 1, 2, 4, 37, 15, 745000, tzinfo=pytz.timezone('UTC'))
    # print(y.astimezone(pytz.timezone('Asia/Shanghai')))
    print(y.astimezone(datetime.timezone(datetime.timedelta(hours=8))))
    print(y.astimezone(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S'))

结果如下

2020-01-02 12:37:15.745000+08:00
2020-01-02 12:37:15

方案2:设置MongoDB的时区

本想应该可以设置时间保存的时区,结果却没有找到相应的方法设置MongoDB中ISODate的默认时区,暂且搁置,如果有新的发现再来补充。

总结

最后思考了下,出现这个问题的原因,一方面是自己思考不足,另一方面跟MongoDB的设计思路有关,它的TTL索引字段只支持ISODate类型,如果没有这个限制,在所有时间字段一律使用时间戳,就避免了这个问题。