这段代码确实有问题,但是可以不用改。

向量数据库大模型关系型数据库

你好呀, 我是苏三。

前几天和团队小伙伴一起 CodeReview 的时候,看到一段有问题的代码,讨论了几句之后,大家很快就达成了一个一致的结论:

这段代码确实有问题,但是可以不用改。

听起来确实是一个很矛盾的结论,所以带着大家一起分析一波。

看完之后你可能会发现:这样的代码,可能在你的项目中也有很多,但是也可以不用改。

啥代码

当时我们讨论的代码片段大概是这样的:

  
Config cfg = configMapper.selectById(id);  
cfg.setKey(newKey);  
cfg.setValue(newValue);  
configMapper.updateById(cfg);  

来,三秒钟过一下脑子,你觉得这个代码片段会有什么问题?

picture.image

我当时看到这个代码,就问了对应的开发同事一句话:你写这个代码的时候,有没有考虑并发的问题。并发的时候,值可能被覆盖哦。

然后开发同事回复了我一下,大概是这个意思:这个代码是我从原来的某个方法拷过来的,粘的时候我想到了,但是考虑到这个是参数修改场景,操作人员固定,且极其低频,这个代码能用,我就没改了。

我也了解这个代码的使用场景,想了一下,实际情况也确实是这样的。

然后就接着往下过了。

整个对话,应该没有超过 10 秒,我们就达成了一致:是有问题,但不用改。

这个问题具体来说,就是一个并发问题。

怎么体现的呢?

你想想这个场景。

假设 id 为 1 的 config 对象,原来的 key=歪歪,value=18,此外还有一个字段 account=123。

对 account 字段的修改,在另外的一个场景中会触发。

配置人员灞波尔奔,打开配置页面,拿到了 key=歪歪,value=18,account=123。

正要把 key 修改为 “灞灞”,value 修改为“19”的时候,另外一个配置人员,奔波儿灞触发了 account 的修改逻辑,想把 account 修改为了 “666”。

然后就有请我们前面的代码片段出场了,你再看看这个代码片段:

  
Config cfg = configMapper.selectById(id);  
cfg.setKey(newKey);  
cfg.setValue(newValue);  
configMapper.updateById(cfg);  

现在,有两个请求:

第一个请求:灞波尔奔要把 key 修改为“灞灞”,value 修改为“19”
第二个请求:奔波儿灞要把 account 修改为“666”。

假设,在执行了 configMapper.selectById(id) 之后,执行 configMapper.updateById(cfg) 之前,account 的值被修改为了 “666”。

执行 updateById 方法的时候,cfg 里面的 account 还是 123,整个对象的值就是这样的:

id=1,key=灞灞,value=19,account=123。

整个片段执行完成之后,account=666 的请求就丢失了。

这个就是问题。

但是,如果代码修改为这样:

  
Config cfg = configMapper.selectById(id);  
Config cfgForUpdate = new Config();  
cfgForUpdate.setId(id);  
cfgForUpdate.setKey(newKey);  
cfgForUpdate.setValue(newValue);  
configMapper.updateById(cfgForUpdate);  

按需修改,就不会有这个问题了。

但是,在我们的实际应用场景中,只会有一个人去操作这些参数,且操作频率极低极低,不会出现两个人同时去执行的情况。

所以,在我们这个大前提下,那个代码片段的 Review 结论就是:

这段代码严格来说确实有问题,但是结合实际情况可以不用改。

在开发人员清楚上面这个结论的前提下,你要有代码洁癖,你就去改了。

你要觉得麻烦,直接提交完事儿。

对应这种场景,有人会建议改,但是你也有自己的理由可以不采纳。

至少我觉得没必要非得打回去。

再想想

那你想想这个代码就没有问题了吗:

  
Config cfg = configMapper.selectById(id);  
Config cfgForUpdate = new Config();  
cfgForUpdate.setId(id);  
cfgForUpdate.setKey(newKey);  
cfgForUpdate.setValue(newValue);  
configMapper.updateById(cfg);  

我再给你举个例子,另外一个场景,还是奔波儿灞和灞波尔奔这两兄弟。

奔波儿灞,打开配置页面,拿到了 key=歪歪,value=18。

把 value 修改为“19”,正准备点击提交的时候,突然想要喝口水,拿起杯子后,发现杯子空了,于是起身到茶水间,去接水。

就在这个接水的期间,灞波尔奔也打开了配置页面,也拿到了 key=歪歪,value=18。

他把 key 从“歪歪”修改成了“灞灞”,并点击了提交,数据被上面的代码片段修改成了:

id=1,key=灞灞,value=18

然后奔波儿灞接完水回来了,在页面点击了提交。

注意哦,这个页面还是他接水之前的页面,所以在这个页面里面点击提交,数据又被修改成了这样:

id=1,key=歪歪,value=19

这种情况怎么办?

我个人认为这种情况,得非情况讨论。

看你这个配置的使用场景,在有的情况下,值被覆盖了就被覆盖了。

表单以最后一个人提交的记录为准,也能狡辩的通。

在我们的实际场景下,这个代码就没问题,还是那个原因:修改配置的人员只会有一个,且修改频率极低,不会出现同时更新的情况。

但是,在有的使用场景下,绝对不允许出现值被覆盖的情况。

怎么办?

也很简单。

这不就是竞态吗?

竞态直接上锁就完事了。

比如我们有个类似的场景,就绝对不允许出现值被覆盖的情况。

所以,某条配置数据被人点击了修改之后,这条数据就被锁住了,在他提交之前,对于这条数据,不允许其他人再次点击修改。

我们上的是一把简单粗暴的悲观锁。

如果你注意用户体验的话,你当然也可以上一把乐观锁。

怎么做?

比如每条数据都有一个 last_update_time 字段,代表最后修改时间,对吧?

那你就可以基于这个字段做乐观锁。

前端页面点击修改的时候,把 last_update_time 也返回去,然后提交的时候又给到后端。

那么 update 的时候可以这样写:

update set xxxx where id=1 and last_update_time=传进来的last_update_time

这样,如果数据的 last_update_time 和查询这条数据时的 last_update_time 不一致,则不会更新成功。

还是前面那个例子,奔波儿灞接完水回来了,在页面点击了提交,由于 id=1 的这条数据,在他接水期间,已经被灞波尔奔更新了,last_update_time 字段发生了变化,数据库就不会更新成功,前端页面就可以提示奔波儿灞:更新失败,请重试。

如果你觉得用 last_update_time 不优雅,那你就优雅的上个 version 字段,专门来干这事儿。

CodeReview 的次数和经验多了,总是要学会和“坏味道”的代码和解的。

所以不管是全量更新:

  
Config cfg = configMapper.selectById(id);  
cfg.setKey(newKey);  
cfg.setValue(newValue);  
configMapper.updateById(cfg);  

还是按需更新:

  
Config cfg = configMapper.selectById(id);  
Config cfgForUpdate = new Config();  
cfgForUpdate.setId(id);  
cfgForUpdate.setKey(newKey);  
cfgForUpdate.setValue(newValue);  
configMapper.updateById(cfg);  

甚至是乐观锁的方案。

在特定场景下,不论是用哪个方案都可以,都能得出下面的这个结论:

这段代码确实有问题,但是可以不用改。

总之,对于这个结论:

有些场景,完全可以。

有些场景,既可以也不可以。

有些场景,绝对不可以。

你是什么情况,自己去分析。

绝对不可以

在我们进行 CodeReview 的时候,涉及到订单、借据、账务之类的资金相关的金融敏感逻辑时,是绝对不可以出现这个结论的,有问题,一定要改。

当代码逻辑是订单、借据、账务相关时,我不会先看业务代码,我首先会看一个主体框架是否存在。

这个主体框架就是:

一锁二判三更新四释放

以借据还款的场景为例。

一锁,指的是在获取借据准进行还款的时候,必须先获取锁,保证同一时刻只有一个线程能访问到这个借据。

而且,这个锁,最好是一个显示的悲观锁。

因为按照经验来说,乐观锁,校验逻辑容易出现漏洞,而且处理不当容易造成系统压力或数据异常,所以资金相关的金融敏感信息不建议使用乐观锁更新,直接上悲观锁,另外牺牲体验,也要确保正确。

但是,需要注意的是,锁的范围应该尽量小,避免不必要的阻塞。

二判,判断借据的状态是否正确、判断本次流水是否已经被处理过了...

总之,在加锁成功后,结合业务场景,再次判断可能发生变化的字段。

三更新,就是经过前面两步之后,你就可以放心的更新数据了,不多解释。

四释放,字面意思,就是别忘了释放锁。

知道“一锁二判三更新四释放”之后,你再去看双重检查锁的 Java 实现,你会发现,它就是这么玩儿的:

  
public class Singleton {  
    // 必须使用volatile修饰实例,禁止指令重排序,保证可见性  
    private static volatile Singleton instance;  
  
    // 私有构造函数,防止外部实例化  
    private Singleton() {}  
  
    public static Singleton getInstance() {  
        if (instance == null) {  
            // 一锁  
            synchronized (Singleton.class) {  
                // 二判  
                if (instance == null) {  
                    // 三更新  
                    instance = new Singleton();  
                }  
            }  
        }  
        return instance;  
    }  
}  

不要问我为什么没有“四释放”。

你要真不知道“释放”在哪,翻开八股文,去巩固一下 synchronized 关键字的原理。

还有一个例子

CodeReview 这个话题是一个摊开了讲,三天三夜都讲不完的话题。

但是既然聊到这里了,我还想起了前段时间遇到的一个新鲜的例子。

在 CodeReview 的时候,看到有一段代码是在进行一系列的判断之后,抛出了一个异常,触发了一个预警。

预警的内容大概是“出现了xxx场景,请人工介入”。

问了一下为什么会是这样。

答复是说这个场景在线上运行时确实可能会出现,但是概率较低。

通过代码也能改,但是改起来,涉及到多个系统,不管是开发或者测试的成本都高,可能还会影响到项目的上线时间。

所以,经过上下游系统相关开发人员和业务条线的业务同事一起评估,最终得出结论,并把结论通过邮件进行留痕:代码不支持这个低概率的场景,但是能监控到并预警出来。如果真的出现了,可以通过刷数的方式解决,具体方案一事一议。

我觉得这应该是一个在工作中会遇到的问题吧。

任何业务场景都能通过技术方案来实现,不过是投入成本高低的问题。

当投入成本过高的时候,脑海中记得浮现出一个词:投入产出比。

收工。

最后欢迎加入苏三的星球,你将获得:AI开发项目课程、苏三AI项目、商城微服务实战、秒杀系统实战、商城系统实战、秒杀系统实战、代码生成工具、系统设计、性能优化、技术选型、底层原理、Spring源码解读、工作经验分享、痛点问题、面试八股文等多个优质专栏。

还有1V1答疑、修改简历、职业规划、送书活动、技术交流。

扫描下方二维码,即可加入星球:

picture.image

目前星球已经更新了5200+篇优质内容,还在持续爆肝中.....

星球已经被官方推荐了3次,收到了小伙伴们的一致好评。戳我加入学习,已有1600+小伙伴加入学习。

0
0
0
0
关于作者
关于作者

文章

0

获赞

0

收藏

0

相关资源
云原生机器学习系统落地和实践
机器学习在字节跳动有着丰富业务场景:推广搜、CV/NLP/Speech 等。业务规模的不断增大对机器学习系统从用户体验、训练效率、编排调度、资源利用等方面也提出了新的挑战,而 Kubernetes 云原生理念的提出正是为了应对这些挑战。本次分享将主要介绍字节跳动机器学习系统云原生化的落地和实践。
相关产品
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论