项目背景
现在要做一个文本标注系统,用于训练大语言模型。该系统后端使用Django实现,管理员使用Django的后台管理系统来创建任务,即上传若干个csv文件,每个csv就是一个任务,每个任务都包含着数千条记录,每个记录有id,问题,答案,纠正答案,备注五个字段,其中纠正答案和备注字段是空的。答案就是大模型根据问题生成的回答,标注员需要有点文字基础,根据大模型给出的回答是否恰当,来填写纠正答案这列,以及备注。
第一阶段,管理员在导出csv文件时,就要求有已经标注好的记录的纠正答案和备注,以及标注员的姓名。在这里,我们在创建记录的模型Record中新增两个字段,state以及update_user,分别代表这条记录的状态以及标注员。其中在state字段中,给予其三个状态值:0,未标注,1,标注中,2,已标注。
所有新创建的记录初始都是未标注,当标注员开始标注时,会先从数据库中搜索该任务号,update_user为该标注员且state为1的记录,即该标注员正在标注的记录,将其返回。若没有,从数据库里中找到该任务号且state为0的记录,返回给该用户,并将update_user更改为该用户,记录数量默认为10。
标注员开始任务后,每条记录有三个选项:保存、跳过、无需修改。保存,即将纠正答案和备注写入数据库。跳过,说明标注员对该记录不敢确定,将其state修改为0,并从前端列表删除。无需修改,会将纠正答案填充为无需修改,这样在管理员导出csv文件时可以判断该任务是标注后不需要修改还是没有标注这条记录。标注员在将现有的记录处理完成后,最后会有一个提交按钮,会将这些记录的state修改为2,说明这些记录已经完成标注。
任务描述
现在需要添加新的功能:质检和打回。添加新的用户角色,质检员。质检员会在已经标注完的记录里进行质检,记录后有一个选项,打回,说明这条记录处理的不够好,会将这条记录打回给原标注员,即将state修改为1。
在初期,设想将state的状态值由原来三个增加到五个:除了0,1,2外,还有,3,质检中,4,质检完。然后在Record表中新加字段,quality_user,表示为质检员。质检员的工作逻辑大致与标注员一样,在开始质检时,会将Record表中state为3,且对应任务号和质检员id的记录返回,若没有,再从state为2的的记录中默认返回10条。
在这种情况下,当质检员打回记录后,标注员可能会突然有新纪录进来,此时,他分不出来哪些是原本申请的任务,哪些是打回的任务。而质检员同样,在标注员将打回记录重新标注后,会被分配给原质检员,此时质检员突然多了任务,也分不出来哪些记录是申请的,哪些记录是打回之后重新提交的,这对于用户体验不是很好。
于是,在考虑后,新增加了字段,quality_state,表示为质检状态,同时为了区分,将原state重命名为annotation_state,update_user改名为annotation_user。现在的逻辑为,annotation_state表示标注状态,分别为0,未标注,1,标注中,2,标注完成。quality_state表示为质检状态,表示为0,未质检,1,质检中,2,质检完。此时,记录的状态如下:
annotation_state | quality_state | 表示情况 |
---|---|---|
0 | 0 | 未标注 |
1 | 0 | 标注中 |
2 | 0 | 未质检 |
0 | 1 | 质检中,记录被打回,记录跳过 |
1 | 1 | 质检中,记录被打回,重新标注 |
2 | 1 | 质检中 |
2 | 2 | 质检完 |
相应的,在数据库查询返回等操作,也做了对应的改变。
现在,还有一个问题,在标注员和质检员的查询历史记录的页面,还是无法区分哪些是被打回的。因此,又加了一个字段,call_back,表示被打回的次数,默认为0。
至此,Record表全部字段为:
字段 | 含义 |
---|---|
record_id | 在文件中的id |
question | 问题 |
answer | 回答 |
modify | 修改答案 |
notes | 备注 |
task | 归属任务 |
annotation_user | 标注用户 |
quality_user | 质检用户 |
annotation_state | 标注状态 |
quality_state | 质检状态 |
call_back | 打回次数 |
发现事故
在上线生产环境后,发现后台管理系统中,标注状态都为未标注且标注员信息为空,明明之前存在标注的记录。在前端页面,所有任务都显示未标注,连接数据库发现,确实annotation_state都是0,annotation_user为空,数据库被修改了!!!
定位影响
在导出的csv文件中发现,原本的纠正答案列,还存在着无需修改,以及一些其他修改过的答案。说明,最重要的纠正答案列没有受影响,辛苦标注的数百条数据结果还在,受到影响的只有记录的状态以及记录的标注人员。统计了一下,在已上传的的两万七千多条记录里,至少有八百多条受到影响,这些记录里,无需修改的有五百多条,有过修改的有三百多条,还有不知道多少已分配还未提交的记录。
排查原因
在上线服务器时,没有进行别的操作,唯一修改数据库的只能是更新时,docker执行的那个脚本
python manage.py makemigrations --merge
python manage.py makemigrations
python manage.py migrate
# 在收集静态文件之前先删除静态文件夹,否则会询问是否覆盖导致报错
rm -rf static
python manage.py collectstatic
gunicorn --bind=0.0.0.0:8000 --access-logfile=/usr/local/var/log/access.log --error-logfile=/usr/local/var/log/error.log --capture-output --workers=1 --threads=1 --timeout 60 TextAnnotationBackend.wsgi
这里会执行迁移文件,所以,就先查看服务器上的迁移文件,在宝塔中找到最新生成的迁移文件
打开文件,惊讶的发现,居然是将state和updated_user给删除了,然后新添加了annotation_state和annotation_user两列。
migrations.RemoveField(
model_name='record',
name='state',
),
migrations.RemoveField(
model_name='record',
name='updated_user',
),
migrations.AddField(
model_name='record',
name='annotation_state',
field=models.IntegerField(choices=[(0, '未标注'), (1, '标注中'), (2, '已标注')], default=0, verbose_name='标注状态'),
),
migrations.AddField(
model_name='record',
name='annotation_user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='updated_records', to=settings.AUTH_USER_MODEL, verbose_name='标注用户'),
),
这和我设想的情况一点也不一样,我明明只是为了区分开,将其改了个名字,django应该对数据库这两列进行alter操作呀,怎么会直接删除了,不过事已至此,赶紧先想办法吧。
寻找解决方案
首先数据库中最重要的纠正答案字段还在,这是值得庆幸的,所以现在可以将数据库中,纠正答案不为空的记录,将其标注状态设置为2,即完成标注,但这样,并不能确定这是完成标注的,有可能是保存但未提交,答案肯定性不能保证。就算这样,标注人员信息还是丢失了,而且还有很多标注状态为1,正在标注的记录状态丢失。
如果放任不管,想一下是什么结果。这些记录标注状态全是0,将会重新分配给标注员,标注员拿到数据后,对于之前标注过的记录,也不会再修改什么内容,大致扫一眼后,差不多就提交了。现在标注记录占比还小,加上最关键的纠正答案字段没受到影响。如果实在没办法,也就这样勉强接受。
后来又想到,数据库的每次操作都会产生一条日志,能否找到日志,按照日志来恢复数据呢?
修复措施
通过查询,MySQL数据库可以通过Binlog日志来恢复数据。Binlog,即binary log,是二进制日志文件,有两个作用,一个是增量备份,另一个是主从复制,即主节点维护一个binlog日志文件,从节点从binlog中同步数据,也可以通过binlog日志来恢复数据。
该功能需要开启,庆幸的是,宝塔默认开启这个功能。
首先进入宝塔的/www/server/data文件夹,查看是否有mysql-bin开头的文件
很幸运,找到了二进制日志文件。然后,找到数据库缺失当天日志。
将其还原成.sql文件,输入以下命令
# 注意:mysqlbinlog的文件位置 与 mysql-bin.0005的文件位置,可能跟你那的位置稍微有点区别
/www/server/mysql/bin/mysqlbinlog --base64-output=DECODE-ROWS -v /www/server/data/mysql-bin.0005 > /www/mysql0005.sql
生成下面sql文件
打开mysql0005.sql文件,里面内容大致如下
这里面有时间信息,24年5月26日,16点24分32秒,进行了一次insert操作。
这是在6月3日的一次update操作。
将文件按照@1到@12,把这些列提取出来,得到以下内容
这便是从创建数据库之初到修改数据库时候各列的一个情况
里面不乏有重复的,冗余的信息,不过是按照时间的先后顺序生成文件,也就懒得筛选,就按照这个顺序重新写入数据库吧。一共有八千行,写个脚本一行一行执行吧。
大概执行了一个半多小时,终于执行完了,检查了下数据库,该有的信息也有了。至此,终于长舒一口气了。
后续保障
这次事故,给我的印象很深刻,在我初次做一个项目,就经历了这么严重的P0级别事故,都怪我不够认真。
给我的教训有,数据备份很重要!!!在对数据库有修改时,一定要备份!
还有使用django迁移之前,要仔细看看迁移文件内容,这次就是没有检查,才发现他将两列删除掉了。
然后是做好向前兼容内容,在增加新功能时,没有太考虑过以前的内容,做的改动也比较多,也容易出错。
所幸这次事故已经挽回了,也给自己提个醒,希望自己长个记性,避免以后再出现这类问题。
后续
还是不明白为什么在django中,只改了模型名就会删除这列并新建一列,那如何才能只修改列名呢?
通过搜索,有的资料说只修改列名,有的说会新建一列,还没搞清什么情况下会新建一列。
可能会有下面原因:
-
数据库引擎的限制:并不是所有的数据库引擎都支持直接重命名列。在某些情况下,特别是跨不同数据库系统,直接重命名列并不是一个普遍支持的操作。为确保一致性和兼容性,Django 选择了通用的解决方案,即删除旧列并创建新列。
-
数据完整性和安全性:直接重命名列可能会有数据丢失或数据不一致的风险,尤其是在涉及到复杂的关系或外键约束时。通过删除旧列并创建新列,Django 可以更好地控制数据迁移过程,确保在应用迁移时不会导致数据损坏。
-
迁移工具的简化:处理数据库迁移是一个复杂的任务。通过统一的删除旧列并创建新列的方式,Django 的迁移工具可以简化处理各种变更的逻辑,而不需要为每种数据库引擎单独处理。
最保险稳妥的做法,是在models中添加新字段、数据迁移、删除旧字段的步骤进行,以确保数据完整性和安全性。