笔者在日常工作中,遇到了一个考试和考场组织系统开发的业务需求,涉及到了考试相关信息项目的数据结构和关系设计,觉得这个体系也是比较经典和常用的,就想要将这些内容包括思考的过程分享出来。
在这个过程中,笔者其实体会到,这个业务和系统的要点和核心,其实是学生在考试项目中的编码规则-就是我们熟悉的准考证编号,本文的内容就是重点围绕这这个编号和其实现过程展开。
基本设定
关于考试大家一般都比较熟悉,我们先总结一下在我们这个业务场景中,有以下相关信息、概念和设定:
- 学校
需要参与考试活动的学校列表,所有参与考试的学生都必然属于某个学校。
- 学生
和考试相关的就主要包括学生基本信息,所属学校,和需要参加考试的类型。
- 考试类型
考试类型用于区别不同考试类型的学生,和他们使用的考场。在高考中,我们熟悉的考试类型就是文理分科。
- 考点
考点为学校的考试活动提供场地。一个考点可以包括多个学校,但一个学校只能使用一个考点来进行考试活动。大多数情况下,考点就设置在学校中,为本校提供服务。对于考生不多的学校,可以使用其他学校作为考点,统一安排考试活动。
其他考点的扩展信息包括考点的地理位置,交通方式等等。
这里在我们的业务场景中,有个重要的概念就是在考试活动中,学校和考点在逻辑上是独立的,它们有关联,但不是一对一的关系,在我们的业务场景中,是考点包括一个或多个学校。每个考点在系统中都有一个唯一的3位编码。
-
考场
一个考点可以有多个考场,每个考场相关的属性包括考场的容量,考场服务于考试的类型,考场编号和在考点中的位置信息等等。一个考场只能容纳相同考试类型的考生进行考试。每个考场都在其考场中有一个3位数字的唯一编码。 -
考试项目
一个考试项目(如高考)是在一段时间内,考生使用相同的模式参加的一系列科目的考试集合。在项目中,考试使用相同的考试号,考试类型和考点考场设置等。一个考试项目可以包括多个考试科目,它们在不同的时间阶段进行。
典型的考试项目就是高考。
所以准考证的编号,就是以考试项目为基础进行的。一个准考证,应该对应特定的考试项目。 -
准考证编码规则
在我们的业务场景中,准考证编码使用8位数字,构成如下:
[考点代码:3位数字][考场代码:3位数字][座位序号:2位数字]。
数据准备
在开始编号之前,需要进行前期的数据结构设计和数据准备工作,针对一个特定的考试项目,和考试号相关的信息和操作如下:
- 学生
需要确定所有报考的学生列表,包括这些学生的考试类型和所属学校。
- 考点
为每个学校都分配和创建对应的考点。
- 考场
我们需要在考点中,设置足够的考场能够容纳考点中参加考试的学生,需要考虑的问题不仅包括学生数量,还需要考虑其考试类型。
一般而言,在一个考点中,考试类型考场数量 = 相关学校类型学生总数/一般考场容量 + 1 ,需要为特殊情况保留冗余考场。
SQL代码实现和分析
数据准备和设置完成之后,一般情况下,我们可以编写程序来为没有编码的学生进行准考证编号。这个可能的过程如下:
1 查询所有未编号学生,并随机排序,得到的记录信息包括所属考点(通过学校关联)、类型
2 遍历这个记录集
3 针对每个学生,使用其考点和类型,来查询对应的考场和容量,并使用考场编号来排序
4 遍历考场,并从1开始,使用小于当前考场容量的编号,来检查此编号是否已经被某个学生使用
5 如果没有人使用,则设置此编号为当前考生的考试号,然后转向下一个学生
6 如果有人使用,则检查当前考场的下一个编号,如果编号超过容量,则进入下一个考场,也是从编号1开始检查
7 循环操作,直到所有未编码学生都设置完毕
可以看到,这个过程是比较复杂的,需要多次循环不同的考场和序号,构造考试号,并且检查是否已经被使用,并且需要在程序和数据库查询之间进行切换和操作。
为此,笔者提出一个新的方法,只使用SQL对相关数据进行批量的检查和处理,实践证明取得了比较好的效果。其基本思路是:
先得到一个所有现在可以使用的考试编码列表(所有考试编码列表去除到已使用的条目),对应所有需要进行编码的学生信息,关联方式使用一个虚拟的ID,这个ID由考点+类型+序号构成。这一通过虚拟ID,就可以基于关联的学生ID,对应其可用的考试编码,从而完成编码过程。
具体操作过程:
1 根据编码规则和基础数据,生成一个当前设置下所有的考试编号列表,包括了考点编号、考试类型和考试编号(由考点考场座位号构成)
2 使用这个列表,和学生列表进行关联查询,过滤掉已经已经使用过的考试号,得到一个可用的考试编号列表,记为D,其中考试编号为idexam2
3 需要在D中,构造一个附加的信息,为考点ID-考试类型-序号(基于考点和类型分组),记为tempid2
4 查询所有未编码学生,并随机排序,得到学生列表S,包括了考点和考试类型,主键为学生ID记为idhash
5 同样在S中,构造一个附加信息,方式同tempid2,记为tempid1
6 关联查询D和S,关联条件为tempid1=tempid2,得到查询结果U
7 基于U中的idhash,对应的idexam2就是当前学生可以使用的考试号,编码完成
基于上面的思路和过程,实际的参考代码如下:
with
S as ( -- students should be examid
select idhash, T.id || '-' || etype || '-' || row_number() over (partition by T.id, etype order by random() ) tempid1 from (
select idhash, college, etype, idexam from students23 where status & 16 = 16 and (idexam is null or idexam = '')
) S1 join emsites T on S1.college = any(colleges) ),
D as ( -- examid should be give
select siteid || '-' || rtype || '-' || row_number() over (partition by siteid, rtype ) tempid2, idexam2 from (
select siteid, rtype, to_char(siteid,'fm000') || to_char(roomid,'fm000') || to_char(generate_series(1,scount),'fm00') idexam2
from emrooms where rtype > 0) E2 left join students23 S2 on idexam2 = S2.idexam where S2.idexam is null),
U as (select idhash idhash2, idexam2 from S join D on tempid1 = tempid2) -- select * from U;
update students23 set idexam = idexam2 from U where idhash = idhash2
笔者的实现使用的数据库系统是postgres,在这个实现中涉及到的相关具体技术和细节包括:
- CTE(With语句)来构造临时数据集,有助于处理厘清过程和思路
- 字符串的连接和构造使用 || 操作符
- 为简化SQL语句,使用了子查询
- 考场和学校之间的关联关系,使用了在考场数据表中的数组字段来表示(非在学校表中增加考场字段),在业务上是解耦的
- 使用to_char来基于基于编码规则对数据进行格式化(考场、考点和座位序号编码)
- 每个考场的容量可能不一样,所以需要未每个考场生成可用编码列表
- 使用窗口函数,确定本记录在分组中的序号,用于构造虚拟关联id
- 使用序号可以对两个本来无直接逻辑关联的记录集进行强行关联
基于上述代码,笔者在测试环境中,使用基本真实的场景和数据进行了测试,相关的设置和结果如下:
- 学生数量:99397
- 学校数量:119
- 考点数量:114
- 考场数量:4684
- 考场容量:140510 (总容量,有裕度)
- 初始状态下耗时: 5880ms
- 单个考场耗时: 1491ms
从上面的数据可以看出,虽然在数据集操作中使用批量数据可能会对资源占用有所浪费,但考虑到这种操作在业务上不会太频繁,在这个规模上的性能上是完全可以接受的。而且这个机制可以支持重复性和累进式的编码操作,可以提供业务上的灵活性和稳定性。
小结
本文研究了使用纯SQL语句在服务器端,基于数据集逻辑对照更新的方式,来实现在考试系统中批量进行准考证编码的问题。类似的思路可以抽象用于任何相似的比如考号、学号编码等场景,它们都是进行分组序号的编码。所以它本质上是一类通用抽象的数据处理方式和技巧,可以帮助我们快速的处理这些的业务数据和需求。