“竞态条件”:一个从刷新引起的 bug 说起
1. 一个难以解决的 bug
当我在测试新写好的创建收藏夹时,点击创建后,页面毫无变化;但只要我手动刷新,新建的收藏夹就会出现在目录中。
经过漫长的排查与翻阅资料,我终于明白这是由于**竞态条件(Race Condition)**所引起的一种“时灵时不灵”的不稳定状态。
2. 什么是竞态条件
可以用一个简单的比喻来帮助理解:
你给朋友寄了一个快递(操作A:写入数据),然后你立刻给他打电话问:“我的快递你收到了吗?”(操作B:读取数据)。
这里就存在一个“竞事”:是你的电话先到,还是快递先到?
- 如果电话先到:朋友会告诉你:“没有啊,没收到。” 尽管你确实已经寄出了。
- 如果快递先到:朋友会告诉你:“收到了,刚拿到!”
最终朋友给你的结果,完全取决于电话和快递这两个独立事件的执行顺序和时机。
在软件世界里,竞态条件的定义是:当多个进程或线程并发地访问和操作同一个共享资源,而最终的结果取决于这些操作执行的相对时间顺序时,就发生了竞态条件。
它的核心要素有三个:
- 共享资源:多个操作都想访问和修改的同一个东西。在我们的例子中,这个资源就是数据库里的
favorites
表。 - 多个操作:至少有两个或以上的操作在几乎同一时间发生。在我们的例子中,这两个操作分别是:
- 写操作:前端发送
POST
请求,要求后端在数据库中INSERT
一条新的收藏夹记录。 - 读操作:前端紧接着发送
GET
请求,要求后端从数据库中SELECT
全部的收藏夹列表。
- 写操作:前端发送
- 不可预知的时序:你无法保证哪个操作会先“完成”。网络延迟、服务器负载、数据库事务的执行时间等,都会给这个过程带来不确定性。
3. 如何解决竞态条件
解决竞态条件的核心思想在于:消除不确定性,确保操作的原子性/顺序性
方案一:后端主导
这应该是最可靠的方法
原理:将“创建”和“获取新列表”这两个操作在后端合并成一个原子操作。前端只发起一次请求,后端保证返回的数据是绝对正确的最终状态。
实现:修改创建收藏夹接口,让它在成功将新数据写入数据库并提交事务后,立刻再次查询数据库,获取包含新成员的完整列表,并将其作为响应体返回给前端。
优点:
- 消除竞态:前端不再需要发起第二次
GET
请求,彻底消除了“读写竞争”的可能。 - 提升性能:减少了一次网络往返。
- 逻辑清晰:创建操作的最终结果由后端直接提供,前端只负责展示
方案二:乐观更新
这是一种提供最佳用户体验的前端技术
原理:UI界面“乐观地”相信后端操作一定会成功。
实现:
- 用户点击“创建”后,JavaScript 不等待后端响应,而是立即在本地的数据列表里模拟一个新收藏夹(可以给一个临时ID),并马上刷新界面。此时用户会立刻看到新建的收藏夹。
- 同时,
fetch
请求在后台发送。 - 若成功:后端返回成功响应(可能包含新收藏夹的正式ID和数据),前端再用真实数据替换掉之前的临时数据。整个过程用户无感知。
- 若失败:后端返回错误。前端需要实现“回滚”逻辑,比如弹窗提示“创建失败”,并将刚刚乐观添加的那个收藏夹从界面上移除。
优点:用户交互感觉极快,没有等待时间。
缺点:前端逻辑更复杂,需要处理状态同步和失败回滚。
4. 问题总结
竞态条件是并发编程中的一个核心概念,它源于对共享资源的无序访问。通过解决这个“刷新才可见”的Bug,我们已经亲身体验并解决了一个典型的前后端竞态条件。
bug 带给我的启示:
- 警惕异步操作:当连续执行多个依赖共享资源的异步操作时,要时刻思考它们的执行顺序是否能得到保证。
- 信任单一数据源:尽量让后端作为唯一可信的数据源,由它来保证操作的原子性,并返回最终的、一致的状态。
评论区
请登录后发表评论