记录一次严重的P0级别线上事故

项目背景

现在要做一个文本标注系统,用于训练大语言模型。该系统后端使用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_stateupdate_user改名为annotation_user。现在的逻辑为,annotation_state表示标注状态,分别为0,未标注,1,标注中,2,标注完成。quality_state表示为质检状态,表示为0,未质检,1,质检中,2,质检完。此时,记录的状态如下:

annotation_statequality_state表示情况
00未标注
10标注中
20未质检
01质检中,记录被打回,记录跳过
11质检中,记录被打回,重新标注
21质检中
22质检完

相应的,在数据库查询返回等操作,也做了对应的改变。
现在,还有一个问题,在标注员和质检员的查询历史记录的页面,还是无法区分哪些是被打回的。因此,又加了一个字段,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

这里会执行迁移文件,所以,就先查看服务器上的迁移文件,在宝塔中找到最新生成的迁移文件
在这里插入图片描述
打开文件,惊讶的发现,居然是将stateupdated_user给删除了,然后新添加了annotation_stateannotation_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中,只改了模型名就会删除这列并新建一列,那如何才能只修改列名呢?
通过搜索,有的资料说只修改列名,有的说会新建一列,还没搞清什么情况下会新建一列。
可能会有下面原因:

  1. 数据库引擎的限制:并不是所有的数据库引擎都支持直接重命名列。在某些情况下,特别是跨不同数据库系统,直接重命名列并不是一个普遍支持的操作。为确保一致性和兼容性,Django 选择了通用的解决方案,即删除旧列并创建新列。

  2. 数据完整性和安全性:直接重命名列可能会有数据丢失或数据不一致的风险,尤其是在涉及到复杂的关系或外键约束时。通过删除旧列并创建新列,Django 可以更好地控制数据迁移过程,确保在应用迁移时不会导致数据损坏。

  3. 迁移工具的简化:处理数据库迁移是一个复杂的任务。通过统一的删除旧列并创建新列的方式,Django 的迁移工具可以简化处理各种变更的逻辑,而不需要为每种数据库引擎单独处理。

最保险稳妥的做法,是在models中添加新字段、数据迁移、删除旧字段的步骤进行,以确保数据完整性和安全性。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值