Django DateTimeField微秒精度导致分页重复数据的排查与修复
本文解析Django中DateTimeField因MySQL 5.6.4+支持微秒精度,导致时间戳转换后比较出现微小差异,进而引发基于时间戳的分页数据重复问题。通过覆盖DatabaseWrapper的data_types属性或修改数据库字段类型,可强制使用无微秒的datetime类型,从而彻底解决该Bug。
前言
近期在项目开发中遭遇一个Django相关的棘手问题,表现为简单的分页功能出现数据重复。经过深入排查,最终定位到DateTimeField字段的精度差异是罪魁祸首。
以下将详细复盘问题背景及涉及的Task Model基本结构定义:
class Task(models.Model): # ...... 省略了其他字段 title = models.CharField(max_length=256, verbose_name=u'标题') created_at = models.DateTimeField(auto_now_add=True, verbose_name=u'创建时间')
问题描述
前端并未采用传统的page和page_size参数进行分页,而是基于游标(Cursor)的分页机制,具体实现为利用创建时间的时间戳作为分页标记。例如,返回的第一页数据如下所示:
{
"data": {
"count": 5,
"has_next": 1,
"tasks": [
{
"title": "这是一个作业标题1",
"ts": 1546829224000,
"id": 1
},
{
"title": "这是一个作业标题2",
"ts": 1546829641000,
"id": 2
}
]
},
"result": 1
}
获取第二页数据时,只需在API请求中传入上一页最后一条记录的时间戳即可。例如传入1546829641000,后端接收到该值后,直接筛选出大于该时间戳的记录并返回一页数据。逻辑看似简单,核心过滤代码如下:
ts = string_utils.get_num(request.GET.get('ts', 0), 0)
alltask = Task.objects.filter(created_at__gt=date_utils.timestamp2datetime(ts))
代码逻辑清晰:将前端传递的时间戳转换为DateTime对象,利用created_at__gt进行大于过滤。然而实际运行中发现,查询结果始终包含上一页的最后一条数据。既然使用的是gt(大于)而非gte(大于等于),为何会出现重复?
为了解开疑惑,我们将上一页最后一条记录的created_at字段值与前端传递的时间戳进行对比打印:
>>> task = Task.objects.get(pk=2) >>> task.created_at datetime.datetime(2019, 1, 7, 10, 54, 1, 343136)
接着,将前端传递的时间戳转换为DateTime对象:
>>> ts = int(1546829641000/1000) >>> date_utils.timestamp2datetime(ts) datetime.datetime(2019, 1, 7, 10, 54, 1)
此时差异显现:数据库查询返回的created_at值包含微秒部分(即343136),而由时间戳转换得到的DateTime对象不含微秒。因此,在过滤条件created_at__gt中,数据库中的值实际上大于转换后的时间对象,导致该条记录被再次返回,数据重复问题由此产生。
解决方法
解决此问题的直观方案是在数据库中新增一个专门存储时间戳的字段,直接基于该字段进行查询过滤,这能避免精度问题。
若坚持使用现有的created_at字段(DateTimeField类型),是否可以通过去除保存数据中的微秒部分来解决问题?
通过查阅Django源码,我们发现DateTimeField包含微秒的原因与MySQL后端配置有关。参考django/db/backends/mysql/base.py文件中的部分代码:
class DatabaseWrapper(BaseDatabaseWrapper):
vendor = 'mysql'
# This dictionary maps Field objects to their associated MySQL column
# types, as strings. Column-type strings can contain format strings; they'll
# be interpolated against the values of Field.__dict__ before being output.
# If a column type is set to None, it won't be included in the output.
_data_types = {
'AutoField': 'integer AUTO_INCREMENT',
'BinaryField': 'longblob',
'BooleanField': 'bool',
'CharField': 'varchar(%(max_length)s)',
'CommaSeparatedIntegerField': 'varchar(%(max_length)s)',
'DateField': 'date',
'DateTimeField': 'datetime',
'DecimalField': 'numeric(%(max_digits)s, %(decimal_places)s)',
'DurationField': 'bigint',
'FileField': 'varchar(%(max_length)s)',
'FilePathField': 'varchar(%(max_length)s)',
'FloatField': 'double precision',
'IntegerField': 'integer',
'BigIntegerField': 'bigint',
'IPAddressField': 'char(15)',
'GenericIPAddressField': 'char(39)',
'NullBooleanField': 'bool',
'OneToOneField': 'integer',
'PositiveIntegerField': 'integer UNSIGNED',
'PositiveSmallIntegerField': 'smallint UNSIGNED',
'SlugField': 'varchar(%(max_length)s)',
'SmallIntegerField': 'smallint',
'TextField': 'longtext',
'TimeField': 'time',
'UUIDField': 'char(32)',
}
@cached_property
def data_types(self):
if self.features.supports_microsecond_precision:
return dict(self._data_types, DateTimeField='datetime(6)', TimeField='time(6)')
else:
return self._data_types
# ... further class methods
上述data_types方法中涉及MySQL版本检查,其中supports_microsecond_precision属性定义在django/db/backends/mysql/features.py中:
class DatabaseFeatures(BaseDatabaseFeatures): # ... properties and methods def supports_microsecond_precision(self): # See https://github.com/farcepest/MySQLdb1/issues/24 for the reason # about requiring MySQLdb 1.2.5 return self.connection.mysql_version >= (5, 6, 4) and Database.version_info >= (1, 2, 5)
代码逻辑表明,若使用的MySQL版本大于等于5.6.4,Django会将DateTimeField映射为数据库中的datetime(6)类型,从而保存包含微秒的数据。
目前Django未提供直接配置该行为的选项。若希望保存的数据不包含微秒,可通过覆盖上述data_types属性来实现:
from django.db.backends.mysql.base import DatabaseWrapper DatabaseWrapper.data_types = DatabaseWrapper._data_types
将上述代码放置在合适位置(如models.py或__init__.py等),在执行migrations命令创建DateTimeField列时,数据库对应字段将被映射为datetime而非datetime(6),即使数据库版本高于5.6.4。
若需立即解决当前已存在的数据重复问题,可直接修改数据库中created_at字段的类型:
mysql> ALTER TABLE `task` CHANGE COLUMN `created_at` `created_at` datetime NOT NULL; Query OK, 156 rows affected (0.14 sec) Records: 156 Duplicates: 0 Warnings: 0
执行上述操作后,数据重复的Bug即告解决。
参考链接:https://stackoverflow.com/questions/46539755/how-to-add-datetimefield-in-django-without-microsecond
总结
以上就是本文的全部内容,希望本文对大家在Django开发或工作中遇到类似问题时提供参考。如有任何疑问,欢迎留言交流,感谢支持。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









