跳到主要内容

个性化学情反馈需求复盘

· 阅读需 11 分钟

工作之后,时间大部分被工作占据,博客写得少了,有时想写却不知道写点什么。既然如此,那就干脆记录一些工作中的事情和收获吧~

最近完成了一个需求,这是个倒排期需求,要的比较急,测试是个新人,基本没怎么测试。最后需求上线后出现了一点小问题,不过还好没什么影响。这个需求就叫它个性化学情反馈吧,本篇会先介绍下这个需求以及技术方案,然后整理下写出的BUG,最后是一点思考总结。

需求描述

这个需求描述起来比较简单,就是要做一个分组群发的功能,根据阶段测学情和课中学情对学生进行分组,不同分组的学生发送不同的文案,文案可以有一些变量,变量需要替换为每个学生各自的值。

image

不过,这些变量的计算却有点麻烦,需要查询多个数据源,还要进行聚合计算。在需求评审时,还提到为了提升体验,对于计算失败的变量,还要展示失败原因。额-_-||

技术实现

完整的技术方案就不写了,只贴点关键的。首先是库表设计,新增两张表:Job表和Task表。

create table tblSceneTouchPrepareJob (
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
assistant_uid bigint not null default 0 comment '辅导UID',
course_id bigint not null default 0 comment '课程ID',
lesson_id bigint not null default 0 comment '章节ID',
scene_type int not null default 0 comment '场景类型',
group_list text comment '分组列表',
scene_context text comment '场景上下文',
param_key varchar(255) not null default '' comment '参数区分键',
success_num int not null default 0 comment '成功任务数',
fail_num int not null default 0 comment '失败任务数',
total_num int not null default 0 comment '总任务数',
`status` int not null default 0 comment '状态 0:未启动 1:执行中 2:执行完成 3:取消执行',
start_time bigint not null default 0 comment '启动时间',
end_time bigint not null default 0 comment '结束时间',
expire_time bigint not null default 0 comment '失效时间',
creator bigint not null default 0 comment '创建者',
updater bigint not null default 0 comment '更新者',
create_time bigint not null default 0 comment '创建时间',
update_time bigint not null default 0 comment '修改时间',
PRIMARY KEY (`id`),
index idx_p (param_key)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='场景化群发数据准备作业';

create table tblSceneTouchPrepareTask (
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
job_id bigint not null default 0 comment '作业ID',
group_name varchar(255) not null default '' comment '分组',
student_uid bigint not null default 0 comment '学员UID',
variables text comment '变量值',
extra text comment '额外数据',
`status` int not null default 0 comment '状态 0:待分配执行 1:已分配执行 2:执行成功 3:执行失败 4:取消执行',
retry int not null default 0 comment '重试次数',
create_time bigint not null default 0 comment '创建时间',
update_time bigint not null default 0 comment '修改时间',
PRIMARY KEY (`id`),
index idx_j (job_id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='场景化群发数据准备任务';

没错,因为变量计算复杂,所以采用发送前预生成的方式,这样如果有变量计算失败也能在发送前拦截。

每一次群发任务是一个Job,一个Job可以拆分为多个Task进行执行,通过消费MQ消息的方式来执行这些Task,在执行的过程中更新Task和Job的状态。

接口设计和变量计算等内容就略过了。

问题复盘

第一个问题可能是当时写的时候有点神志不清,忘了这里是多章节的,在学生维度上计算没有进行累加,就导致计算错误。

// 修改前
for _, lu := range commonLuRsp.List {
if lu.InclassParticipateCnt+lu.PlaybackParticipateCnt > 0 {
studentInclassRightCnt[lu.StudentUid] = lu.InclassRightCnt + lu.PlaybackRightCnt
studentInclassParticipateCnt[lu.StudentUid] = lu.InclassParticipateCnt + lu.PlaybackParticipateCnt
} else {
studentInclassRightCnt[lu.StudentUid] = 0
studentInclassParticipateCnt[lu.StudentUid] = 0
}
}

// 修改后
for _, lu := range commonLuRsp.List {
studentInclassRightCnt[lu.StudentUid] += lu.InclassRightCnt + lu.PlaybackRightCnt
studentInclassParticipateCnt[lu.StudentUid] += lu.InclassParticipateCnt + lu.PlaybackParticipateCnt
}

第二个问题是因为需求涉及阶段测对应的章节和课中学情对应的章节,由于计算逻辑挺麻烦的,写懵了,有的地方写反了。。。😂

// 修改前
LessonIds: fwyyutils.JoinArrayInt64ToString(h.sc.LessonIds, ","),

// 修改后
LessonIds: fwyyutils.JoinArrayInt64ToString([]int64{h.sc.ExamLessonId}, ","),

第三个问题是业务逻辑实现上有点问题。预期是:对于薄弱知识目标,如果在解读话术中有错因分析,就展示“薄弱知识目标:错因分析”,否则就只展示薄弱知识目标。实际是:对于薄弱知识目标,如果在解读话术中有错因分析,就展示“薄弱知识目标:错因分析”,如果在解读话术中没有错因分析就只展示薄弱知识目标,如果没有解读话术就不展示。感觉有点绕啊。。。😂

第四个应该算需求变更。由于阶段测可能存在主观题需要批改,所以阶段测还需要考虑批改状态,因为未批改状态下也是有分数的,也就是客观题的分数。

第五个问题是由于对阶段测业务不了解导致的吧,毕竟不是我的工作范畴。对于阶段测,或者其它类型的测试试卷,是可以绑定到大纲或章节上的,并且优先取绑定到章节上的,如果章节上有就取绑定在章节上的,如果没有再看大纲上有没有。(大纲可以理解为章节的母版)我当时就没想那么多,章节和大纲上的一股脑都取出来了,后来才知道还有这么一个逻辑。而且不同章节还可以绑定同一个阶段测试卷,然后学生在其中一个章节作答后,有的数据源会把数据同步到绑定同一阶段测的其它章节,而有的数据源却并不会同步。有点乱啊。。。😂

第六个问题算代码BUG,还是看代码吧:

// 修改前
stageLuActionMap := make(map[int64]*dataproxy.LessonStudentActionItem)
for _, luAction := range stageLuActionRsp.List {
// ...
stageLuActionMap[luAction.StudentUid] = &luAction
}

// 修改后
stageLuActionMap := make(map[int64]dataproxy.LessonStudentActionItem)
for _, luAction := range stageLuActionRsp.List {
// ...
stageLuActionMap[luAction.StudentUid] = luAction
}

修改前的代码有个问题,stageLuActionMap 所有的值都是最后一个luAction 。这是因为在遍历时,luAction 指向的是一个临时变量的地址,并不是指向对应元素本来的地址,所以修改前的代码在赋值时,赋值的都是这个临时变量的地址,所以值也都是一样的,等于最后一个luAction 的值。或者说,Go遍历struct切片时,对于struct本身是值传递,而不是引用传递。在修改后的代码里,没有使用指针,赋值时使用的是struct副本。所以说,用指针得谨慎!

Go的这一遍历struct切片的行为和Java语言遍历对象数组的行为有点不同。Java是浅拷贝的,而Go,对于struct本身是值传递,但对于struct里的指针、切片、map等引用类型字段仍然是指向同一块内存的。也好理解,引用类型在struct里存储的其实是个指针,所以struct在进行值传递时,副本也是存储的指针,且指针的指向相同。

前面的问题都是在上线投入使用前就已经发现并解决了,而最后一个问题则是在投入使用后才发现的。而且,这个问题是偶发的,且只有在线上环境才会触发。这个问题其实值得单独写一篇的,不过呢,我比较懒,就还是放在本篇一起介绍了。

前面提到,一个Job会拆分为多个Task进行执行,且Task在执行的过程中需要更新Job的状态,也就是成功任务数和失败任务数,还有Job的状态。多个Task会被MQ分发到不同的消费者上执行,为了确保Job状态的一致性,因此在修改同一Job时需要分布式锁。只有获取到锁的消费者才可以执行Job的修改,如果消费者没有获取到锁,就重试获取锁,如果多次仍未获取到锁,就消费失败。

这是之前别人写的重试获取分布式锁的方法,看到有多处使用我也就没太在意,没想到这个实现有点小问题。

func LockRetry(ctx *gin.Context, key string, value interface{}, expire uint64, retry int) (result bool, err error) {
if retry > 10 {
zlog.Infof(ctx, "重试次数不能超过10次,入参为%+v。", retry)
retry = 10
}
count := 0
for count < retry {
result, err = helpers.RedisClient.SetNxByEX(ctx, key, value, expire)
if err != nil {
zlog.Warnf(ctx, "获取分布式锁异常,key:%+v,value:%+v", key, value)
count += 1
time.Sleep(time.Millisecond * 300)
continue
}
if result {
return
}
zlog.Infof(ctx, "获取分布式锁失败,key:%+v,value:%+v", key, value)
count += 1
time.Sleep(time.Millisecond * 300)
}
return
}

逻辑非常简单,最多重试10次,每次重试都固定等300ms。

这样做有什么问题呢?当有多个线程尝试获取锁时,发生锁竞争,其中一个线程拿到锁了,其余线程都固定等300ms然后再次尝试获取锁,然后还是其中一个线程拿到锁了。由于最多重试10次,假如超过10个线程发生锁竞争,那么就会有线程拿不到锁。而且,300ms在系统中其实挺久了,Redis操作一般4ms,MySQL一个update一般20ms,当然只是参考值,实际可能有所波动,但一般来说是远远不需要300ms的。不过,也许这个方法的本意只是为了单次加锁,只是稍微重试下避免网络波动等导致的失败,而并没有想解决很多线程的锁竞争问题。

为了解决这一问题,每次重试等待随机的一段时间,并且重试次数较多时需要更久的等待,也就是“退避抖动”。下面是我的实现:

func GetSpinLock(ctx *gin.Context, key, value string, expireSeconds, spinTimeoutSeconds uint64) (result bool, err error) {
_rand := rand.New(rand.NewSource(time.Now().UnixNano()))
randInt := func(a, b int) int {
return _rand.Intn(b-a+1) + a
}
startTime := time.Now()
for retries := 1; ; retries++ {
result, err = helpers.RedisClient.SetNxByEX(ctx, key, value, expireSeconds)
if err == nil && result { // 加锁成功
return
}

if time.Since(startTime) >= time.Duration(spinTimeoutSeconds)*time.Second {
zlog.Infof(ctx, "GetSpinLock timeout, key:%+v,value:%+v, retries:%+v, err:%+v", key, value, retries, err)
return false, err
}

// 使用退避抖动
base, _cap := 16, 256
sleep := randInt(base, min(_cap, base*(1<<min(30, retries))))
time.Sleep(time.Duration(sleep) * time.Millisecond)
}
}

没有使用重试次数这样的参数,而是使用自旋时间,这样可以结合锁竞争的程度、持有锁时长等因素来进行设置。

提到退避,可能有的读者想到了HTTP协议里的指数退避算法,可以看到我的实现里也有指数退避的影子,不过在此基础上加上了一些随机,也就是抖动,这样可以更好地将线程的等待时间打散。

退避抖动,英文是"Backoff And Jitter",网上的 Exponential Backoff And Jitter | AWS Architecture Blog 这篇文章我觉得写得还不错,可以参考一下。它在指数退避的基础上使用了不同的抖动算法,并对这些算法进行了比较。

1、无抖动。在指数退避的基础上添加了上界。

sleep = min(cap, base * 2 ** attempt)

2、完全抖动

sleep = random_between(0, min(cap, base * 2 ** attempt))

3、相等抖动

temp = min(cap, base * 2 ** attempt)
sleep = temp / 2 + random_between(0, temp / 2)

4、去关联抖动

temp = min(cap, base * 2 ** attempt)
sleep = temp / 2 + random_between(0, temp / 2)
sleep = min(cap, random_between(base, sleep * 3))

我的实现其实就是在完全抖动的基础上添加了下界,因为完全抖动的表现挺好的,而且实现也简单。

思考总结

本以为把工作相关的整理一下发篇博客应该挺快的,结果还是洋洋洒洒花了近3小时。虽然现在大模型很强大,可以解决很多问题,写博客的意义似乎没多大了,但我还是想以这种方式记录一下。通过输出倒逼输入,将复盘沉淀一下,也让我重新梳理了下过程中出现的问题,对Go的遍历、退避抖动等内容也有了更深入的认识,也敦促自己保持高质量的交付。