Redis 与 MySQL 数据一致性问题

1. 什么是数据库与缓存一致性

数据一致性指的是:

  • 缓存中存有数据,缓存的数据值 = 数据库中的值;
  • 缓存中没有该数据,数据库中的值 = 最新值。

反推缓存与数据库不一致:

  • 缓存的数据值 ≠ 数据库中的值;
  • 缓存或者数据库存在旧的数据,导致线程读取到旧数据。

为何会出现数据一致性问题呢?

把 Redis 作为缓存的时候,当数据发生改变我们需要双写来保证缓存与数据库的数据一致。

数据库跟缓存,毕竟是两套系统,如果要保证强一致性,势必要引入 2PC 或 Paxos 等分布式一致性协议,或者分布式锁等等,这个在实现上是有难度的,而且一定会对性能有影响。

如果真的对数据的一致性要求这么高,那引入缓存是否真的有必要呢?

2. 缓存的使用策略

在使用缓存时,通常有以下几种缓存使用策略用于提升系统性能:

  • Cache-Aside Pattern(旁路缓存,业务系统常用)
  • Read-Through Pattern
  • Write-Through Pattern
  • Write-Behind Pattern

2.1 Cache-Aside (旁路缓存)

所谓「旁路缓存」,就是读取缓存、读取数据库和更新缓存的操作都在应用系统来完成业务系统最常用的缓存策略

2.1.1 读取数据

图片

读取数据逻辑如下:

  1. 当应用程序需要从数据库读取数据时,先检查缓存数据是否命中。
  2. 如果缓存未命中,则查询数据库获取数据,同时将数据写到缓存中,以便后续读取相同数据会命中缓存,最后再把数据返回给调用者。
  3. 如果缓存命中,直接返回。

时序图如下:

图片

旁路缓存读时序图

优点
  • 缓存中仅包含应用程序实际请求的数据,有助于保持缓存大小的成本效益。
  • 实现简单,并且能获得性能提升。

实现的伪代码如下:

String cacheKey = "公众号:码哥字节";
String cacheValue = redisCache.get(cacheKey);
//缓存命中
if (cacheValue != null) {
  return cacheValue;
} else {
  //缓存缺失, 从数据库获取数据
  cacheValue = getDataFromDB();
  // 将数据写到缓存中
  redisCache.put(cacheValue)
}
缺点

由于数据仅在缓存未命中后才加载到缓存中,因此初次调用的数据请求响应时间会增加一些开销,因为需要额外的缓存填充和数据库查询耗时。

2.1.2 更新数据

使用 cache-aside 模式写数据时,如下流程。

图片

旁路缓存写数据

  1. 写数据到数据库;
  2. 将缓存中的数据失效或者更新缓存数据;

使用 cache-aside 时,最常见的写入策略是直接将数据写入数据库,但是缓存可能会与数据库不一致。

我们应该给缓存设置一个过期时间,这个是保证最终一致性的解决方案。

如果过期时间太短,应用程序会不断地从数据库中查询数据。同样,如果过期时间过长,并且更新时没有使缓存失效,缓存的数据很可能是脏数据。

最常用的方式是删除缓存使缓存数据失效

为啥不是更新缓存呢?

性能问题

当缓存的更新成本很高,需要访问多张表联合计算,建议直接删除缓存,而不是更新缓存数据来保证一致性。

安全问题

在高并发场景下,可能会造成查询查到的数据是旧值,具体待会码哥会分析,大家别急。

2.2 Read-Through(直读)

当缓存未命中,也是从数据库加载数据,同时写到缓存中并返回给应用系统。

虽然 read-through 和 cache-aside 非常相似,在 cache-aside 中应用系统负责从数据库获取数据和填充缓存。

而 Read-Through 将获取数据存储中的值的责任转移到了缓存提供者身上。

图片

Read-Through

Read-Through 实现了关注点分离原则。代码只与缓存交互,由缓存组件来管理自身与数据库之间的数据同步。

2.3 Write-Through 同步直写

与 Read-Through 类似,发生写请求时,Write-Through 将写入责任转移到缓存系统,由缓存抽象层来完成缓存数据和数据库数据的更新,时序流程图如下:

图片

Write-Through

Write-Through 的主要好处是应用系统的不需要考虑故障处理和重试逻辑,交给缓存抽象层来管理实现。

优缺点

单独直接使用该策略是没啥意义的,因为该策略要先写缓存,再写数据库,对写入操作带来了额外延迟。

Write-Through 与 Read-Through 配合使用,就能成分发挥 Read-Through 的优势,同时还能保证数据一致性,不需要考虑如何将缓存设置失效。

图片

Write-Through

这个策略颠倒了 Cache-Aside 填充缓存的顺序,并不是在缓存未命中后延迟加载到缓存,而是在数据先写缓存,接着由缓存组件将数据写到数据库

优点
  • 缓存与数据库数据总是最新的;
  • 查询性能最佳,因为要查询的数据有可能已经被写到缓存中了。
缺点

不经常请求的数据也会写入缓存,从而导致缓存更大、成本更高。

2.4 Write-Behind

这个图一眼看去似乎与 Write-Through 一样,其实不是的,区别在于最后一个箭头的箭头:它从实心变为线。

这意味着缓存系统将异步更新数据库数据,应用系统只与缓存系统交互

应用程序不必等待数据库更新完成,从而提高应用程序性能,因为对数据库的更新是最慢的操作。

Write-Behind

这种策略下,缓存与数据库的一致性不强,对一致性高的系统不建议使用。

3. 旁路缓存下的一致性问题分析

业务场景用的最多的就是 Cache-Aside (旁路缓存) 策略,在该策略下,客户端对数据的读取流程是先读取缓存,如果命中则返回;未命中,则从数据库读取并把数据写到缓存中,所以读操作不会导致缓存与数据库的不一致。

重点是写操作,数据库和缓存都需要修改,而两者就会存在一个先后顺序,可能会导致数据不再一致。针对写,我们需要考虑两个问题:

  • 先更新缓存还是更新数据库?
  • 当数据发生变化时,选择修改缓存(update),还是删除缓存(delete)?

将这两个问题排列组合,会出现四种方案:

  1. 先更新缓存,再更新数据库;
  2. 先更新数据库,再更新缓存;
  3. 先删除缓存,再更新数据库;
  4. 先更新数据库,再删除缓存。

接下来的分析大家不必死记硬背,关键在于在推演的过程中大家只需要考虑以下两个场景会不会带来严重问题即可:

  • 其中第一个操作成功,第二个失败会导致什么问题?
  • 在高并发情况下会不会造成读取数据不一致?

为啥不考虑第一个失败,第二个成功的情况呀?

你猜?

既然第一个都失败了,第二个就不用执行了,直接在第一步返回 50x 等异常信息即可,不会出现不一致问题。

只有第一个成功,第二个失败才让人头痛,想要保证他们的原子性,就涉及到分布式事务的范畴了。

3.1 先更新缓存,再更新数据库

先更新缓存再更新数据库

如果先更新缓存成功,写数据库失败,就会导致缓存是最新数据,数据库是旧数据,那缓存就是脏数据了。

之后,其他查询立马请求进来的时候就会获取这个数据,而这个数据数据库中却不存在。

数据库都不存在的数据,缓存并返回客户端就毫无意义了。

该方案直接 Pass

3.2 先更新数据库,再更新缓存

一切正常的情况如下:

  • 先写数据库,成功;
  • 再 update 缓存,成功。
更新缓存失败

这时候我们来推断下,假如这两个操作的原子性被破坏:第一步成功,第二步失败会导致什么问题?

会导致数据库是最新数据,缓存是旧数据,出现一致性问题。

该图我就不画了,与上一个图类似,对调下 Redis 和 MySQL 的位置即可。

高并发场景

谢霸歌经常 996,腰酸脖子疼,bug 越写越多,想去按摩推拿放提升下编程技巧。

疫情影响,单子来之不易,高端会所的技师都争先恐后想接这一单,高并发啊兄弟们。

在进店以后,前台会将顾客信息录入系统,执行 set xx的服务技师 = 待定的初始值表示目前无人接待保存到数据库和缓存中,之后再安排技师按摩服务。

如下图所示:

高并发先更新数据库,再更新缓存

  1. 98 号技师先下手为强,向系统发送 set 谢霸歌的服务技师 = 98 的指令写入数据库,这时候系统的网络出现波动,卡顿了,数据还没来得及写到缓存
  2. 接下来,520 号技师也向系统发送 set 谢霸哥的服务技师 = 520写到数据库中,并且也把这个数据写到缓存中了。
  3. 这时候之前的 98 号技师的写缓存请求开始执行,顺利将数据 set 谢霸歌的服务技师 = 98 写到缓存中。

最后发现,数据库的值 = set 谢霸哥的服务技师 = 520,而缓存的值= set 谢霸歌的服务技师 = 98

520 号技师在缓存中的最新数据被 98 号技师的旧数据覆盖了。

所以,在高并发的场景中,多线程同时写数据再写缓存,就会出现缓存是旧值,数据库是最新值的不一致情况。

该方案直接 pass。

如果第一步就失败,直接返回 50x 异常,并不会出现数据不一致。

3.3 先删缓存,再更新数据库

按照「码哥」前面说的套路,假设第一个操作成功,第二个操作失败推断下会发生什么?高并发场景下又会发生什么?

第二步写数据库失败

假设现在有两个请求:写请求 A,读请求 B。

写请求 A 第一步先删除缓存成功,写数据到数据库失败,就会导致该次写数据丢失,数据库保存的是旧值

接着另一个读请 B 求进来,发现缓存不存在,从数据库读取旧数据并写到缓存中。

高并发下的问题

先删缓存,再写数据库

  1. 还是 98 号技师先下手为强,系统接收请求把缓存数据删除,当系统准备将 set 肖菜鸡的服务技师 = 98写到数据库的时候发生卡顿,来不及写入。
  2. 这时候,大堂经理向系统执行读请求,查下肖菜鸡有没有技师接待,方便安排技师服务,系统发现缓存中没数据,于是乎就从数据库读取到旧数据 set 肖菜鸡的服务技师 = 待定,并写到缓存中。
  3. 这时候,原先卡顿的 98 号技师写数据 set 肖菜鸡的服务技师 = 98到数据库的操作完成。

这样子会出现缓存的是旧数据,在缓存过期之前无法读取到最数据。 肖菜鸡本就被 98 号技师接单了,但是大堂经理却以为没人接待。

该方案 pass,因为第一步成功,第二步失败,会造成数据库是旧数据,缓存中没数据继续从数据库读取旧值写入缓存,造成数据不一致,还会多一次 cahche。

不论是异常情况还是高并发场景,会导致数据不一致。miss。

3.4 先更新数据库,再删缓存

经过前面的三个方案,全都被 pass 了,分析下最后的方案到底行不行。

按照「套路」,分别判断异常和高并发会造成什么问题。

该策略可以知道,在写数据库阶段失败的话就直返返回客户端异常,不需要执行缓存操作了。

所以第一步失败不会出现数据不一致的情况。

删缓存失败

重点在于第一步写最新数据到数据库成功,删除缓存失败怎么办?

可以把这两个操作放在一个事务中,当缓存删除失败,那就把写数据库回滚。

高并发场景下不合适,容易出现大事务,造成死锁问题。

如果不回滚,那就出现数据库是新数据,缓存还是旧数据,数据不一致了,咋办?

所以,我们要想办法让缓存删除成功,不然只能等到有效期失效那可不行。

使用重试机制。

比如重试三次,三次都失败则记录日志到数据库,使用分布式调度组件 xxl-job 等实现后续的处理。

在高并发的场景下,重试最好使用异步方式,比如发送消息到 mq 中间件,实现异步解耦。

亦或是利用 Canal 框架订阅 MySQL binlog 日志,监听对应的更新请求,执行删除对应缓存操作。

高并发场景

再来分析下高并发读写会有什么问题……

先写数据库后删缓存

  1. 98 号技师先下手为强,接下肖菜鸡的这笔生意,数据库执行 set 肖菜鸡的服务技师 = 98;还是网络卡顿了下,没来得及执行删除缓存操作
  2. 主管 Candy 向系统执行读请求,查下肖菜鸡有没有技师接待,发现缓存中有数据 肖菜鸡的服务技师 = 待定,直接返回信息给客户端,主管以为没人接待。
  3. 原先 98 号技师接单,由于卡顿没删除缓存的操作现在执行删除成功。

读请求可能出现少量读取旧数据的情况,但是很快旧数据就会被删除,之后的请求都能获取最新数据,问题不大。

还有一种比较极端的情况,缓存自动失效的时候又遇到了高并发读写的情况,假设这会有两个请求,一个线程 A 做查询操作,一个线程 B 做更新操作,那么会有如下情形产生:

缓存忽然失效

  1. 缓存的过期时间到期,缓存失效。
  2. 线程 A 读请求读取缓存,没命中,则查询数据库得到一个旧的值(因为 B 会写新值,相对而言就是旧的值了),准备把数据写到缓存时发送网络问题卡顿了
  3. 线程 B 执行写操作,将新值写数据库。
  4. 线程 B 执行删除缓存。
  5. 线程 A 继续,从卡顿中醒来,把查询到的旧值写到入缓存。

码哥,这咋玩,还是出现了不一致的情况啊。

不要慌,发生这个情况的概率微乎其微,发生上述情况的必要条件是:

  1. 步骤 (3)的写数据库操作要比步骤(2)读操作耗时短速度快,才可能使得步骤(4)先于步骤(5)。
  2. 缓存刚好到达过期时限。

通常 MySQL 单机的 QPS 大概 5K 左右,而 TPS 大概 1k 左右,(ps:Tomcat 的 QPS 4K 左右,TPS = 1k 左右)。

数据库读操作是远快于写操作的(正是因为如此,才做读写分离),所以步骤(3)要比步骤(2)更快这个情景很难出现,同时还要配合缓存刚好失效。

所以,在用旁路缓存策略的时候,对于写操作推荐使用:先更新数据库,再删除缓存。

4. 一致性解决方案有哪些?

最后,针对 Cache-Aside (旁路缓存) 策略,写操作使用先更新数据库,再删除缓存的情况下,我们来分析下数据一致性解决方案都有哪些?

4.1 缓存延时双删

如果采用先删除缓存,再更新数据库如何避免出现脏数据?

采用延时双删策略。

  1. 先删除缓存。
  2. 写数据库。
  3. 休眠 500 毫秒,再删除缓存。

这样子最多只会出现 500 毫秒的脏数据读取时间。关键是这个休眠时间怎么确定呢?

延迟时间的目的就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

所以我们需要自行评估项目的读数据业务逻辑的耗时,在读耗时的基础上加几百毫秒作为延迟时间即可

4.2 删除缓存重试机制

缓存删除失败怎么办?比如延迟双删的第二次删除失败,那岂不是无法删除脏数据。

使用重试机制,保证删除缓存成功。

比如重试三次,三次都失败则记录日志到数据库并发送警告让人工介入。

在高并发的场景下,重试最好使用异步方式,比如发送消息到 mq 中间件,实现异步解耦。

图片

重试机制

第(5)步如果删除失败且未达到重试最大次数则将消息重新入队,直到删除成功,否则就记录到数据库,人工介入。

该方案有个缺点,就是对业务代码中造成侵入,于是就有了下一个方案,启动一个专门订阅 数据库 binlog 的服务读取需要删除的数据进行缓存删除操作。

4.3 读取 binlog 异步删除

图片

binlog异步删除

  1. 更新数据库;
  2. 数据库会把操作信息记录在 binlog 日志中;
  3. 使用 canal 订阅 binlog 日志获取目标数据和 key;
  4. 缓存删除系统获取 canal 的数据,解析目标 key,尝试删除缓存。
  5. 如果删除失败则将消息发送到消息队列;
  6. 缓存删除系统重新从消息队列获取数据,再次执行删除操作。

总结

缓存策略的最佳实践是 Cache Aside Pattern。 分别分为读缓存最佳实践和写缓存最佳实践。

读缓存最佳实践:先读缓存,命中则返回;未命中则查询数据库,再写到数据库。

写缓存最佳实践:

  • 先写数据库,再操作缓存;
  • 直接删除缓存,而不是修改,因为当缓存的更新成本很高,需要访问多张表联合计算,建议直接删除缓存,而不是更新,另外,删除缓存操作简单,副作用只是增加了一次 chache miss,建议大家使用该策略。

在以上最佳实践下,为了尽可能保证缓存与数据库的一致性,我们可以采用延迟双删。

防止删除失败,我们采用异步重试机制保证能正确删除,异步机制我们可以发送删除消息到 mq 消息中间件,或者利用 canal 订阅 MySQL binlog 日志监听写请求删除对应缓存。

那么,如果我非要保证绝对一致性怎么办,先给出结论:

没有办法做到绝对的一致性,这是由 CAP 理论决定的,缓存系统适用的场景就是非强一致性的场景,所以它属于 CAP 中的 AP。

所以,我们得委曲求全,可以去做到 BASE 理论中说的最终一致性

其实一旦在方案中使用了缓存,那往往也就意味着我们放弃了数据的强一致性,但这也意味着我们的系统在性能上能够得到一些提升。

所谓 tradeoff 正是如此。

作者:鼓楼丶
链接:https://juejin.cn/post/7103701796429758478

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/539320.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

HCIP-Datacom(H12-821)题库补充(4月12日)

最新 HCIP-Datacom(H12-821)完整题库请扫描上方二维码访问,持续更新中。 在BGP进程下,Aggregate命令中的detail_suppressed关键字的作用是以下哪一项? A:抑制生成的聚合路由下发IP路由表 B&…

2024-简单点-观察者模式

先看代码: # 导入未来模块以支持类型注解 from __future__ import annotations# 导入抽象基类模块和随机数生成器 from abc import ABC, abstractmethod from random import randrange# 导入列表类型注解 from typing import List# 定义观察者模式中的主体接口&…

R语言绘图:绘制横向柱状图

代码主要实现&#xff1a; 对数据进行排序&#xff0c;并且相同分组的数据会有相同的颜色。最后&#xff0c;绘制横向柱状图。 # 加载ggplot2包 library(ggplot2)# 示例数据&#xff0c;假设有三列&#xff1a;Group, Variable, Value data <- data.frame(Group factor(c(…

GGUF类型模型文件

在HuggingFace上&#xff0c;我们时不时就会看到GGUF后缀的模型文件&#xff0c;它是如何来的&#xff1f;有啥特点&#xff1f; https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF GGUF 由来 Georgi Gerganov&#xff08;https://github.com/ggerganov&#xff09;是著…

机器学习基础入门(一)(机器学习定义及分类)

机器学习定义 给予计算机无需特意带有目的性编程便有学习能力的算法 深度学习算法 主要有监督学习和非监督学习两类 监督学习&#xff08;supervised learning&#xff09; 定义 1、学习由x映射到y的映射关系 2、主动给予机器学习算法正确示例&#xff0c;算法通过示例来学习…

Springboot+Vue项目-基于Java+MySQL的旅游网站系统(附源码+演示视频+LW)

大家好&#xff01;我是程序猿老A&#xff0c;感谢您阅读本文&#xff0c;欢迎一键三连哦。 &#x1f49e;当前专栏&#xff1a;Java毕业设计 精彩专栏推荐&#x1f447;&#x1f3fb;&#x1f447;&#x1f3fb;&#x1f447;&#x1f3fb; &#x1f380; Python毕业设计 &…

政安晨:【Keras机器学习实践要点】(二十七)—— 使用感知器进行图像分类

目录 简介 设置 准备数据 配置超参数 使用数据增强 实施前馈网络&#xff08;FFN&#xff09; 将创建修补程序作为一个层 实施补丁编码层 建立感知器模型 变换器模块 感知器模型 编译、培训和评估模式 政安晨的个人主页&#xff1a;政安晨 欢迎 &#x1f44d;点赞✍…

改写STM32标准库函数中的fputc

int fputc(int ch, FILE *f) {unsigned char temp[1] {ch};HAL_UART_Transmit(&huart1, temp, 1, 0xFFFF);return ch; // 或者返回 0&#xff0c;表示写入成功 }标准库中的 printf 函数在执行输出时会调用 fputc 函数&#xff0c;将字符一个个发送到输出流中。通过重写 fp…

redis-哨兵模式

一&#xff0c;哨兵的作用&#xff1a; 通过发送命令&#xff0c;让Redis服务器返回监控其运行状态&#xff0c;包括主服务器和从服务器。当哨兵监测到master宕机&#xff0c;会自动将slave切换成master&#xff0c;然后通过发布订阅模式通知其他的从服务器&#xff0c;修改配…

数仓维度建模

维度建模 数仓建模方法1. 范式建模法&#xff08;Third Normal Form&#xff0c;3NF&#xff09;2. 维度建模法&#xff08;Dimensional Modeling&#xff09;3. 实体建模法&#xff08;Entity Modeling&#xff09; 维度建模1. 事实表事实表种类事务事实表周期快照事实表累计快…

008Node.js模块、自定义模块和CommonJs

CommonJS API定义很多普通应用程序(主要指非浏览器的应用)使用的API&#xff0c;从而填补了这个空白。它的终极目标是提供一个类似Python&#xff0c;Ruby和Java标 准库。这样的话&#xff0c;开发者可以使用CommonJS API编写应用程序&#xff0c;然后这些应用可以运行在不同的…

强大的数据分析计算软件:Stata 15 for Mac 激活版

Stata 15 for Mac是一款高级统计分析软件&#xff0c;具有强大的数据管理和数据提取工具。以下是其功能和特点的详细介绍&#xff1a; 软件下载&#xff1a;Stata 15 for Mac 激活版版下载 数据管理&#xff1a;Stata 15 for Mac支持多种数据库、数据格式和计算机语言&#xff…

软件定义车队面临网络安全的曲折之路

当以色列 REE Automotive 设计其 P7 电动汽车底盘时&#xff0c;它是从软件开始工作的&#xff1a;扁平的车辆底盘完全可配置&#xff0c;每个轮胎附近有四个独立的模块&#xff0c;用于转向、制动、悬架和动力传动系统&#xff0c;每个模块均由电子驱动控制单元&#xff08;EC…

小程序地理位置权限申请+uniapp调用uni.getLocation

文章目录 一、小程序地理位置权限申请二、uniapp调用uni.getLocation 一、小程序地理位置权限申请 需要确保小程序类目已经填写 点击左侧导航栏找到最后的“设置”——“基本设置”——“前往填写” 在开发管理——接口设置——地理位置中可以看到&#xff1a; 即可点击想要申…

python与设计模式之工厂模式的那些事儿

一、工厂模式 工厂模式实现了按需创建的最佳模式&#xff0c;其目的是为了隐藏创建类的细节与过程&#xff0c;通过一个统一的接口来创建所需的对象。 话说没了皇位争夺权的皇三接到了一个外征的工作&#xff0c;始皇给了5个亿的经费让皇三组建一个军队。打权总是要进行武器采…

ClickHouse--16--普通函数

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 一、日期函数1、时间或日期截取函数&#xff08;返回非日期&#xff09;2、时间或日期截取函数&#xff08;返回日期&#xff09;3、日期或时间日期生成函数 二、类…

httpsok-快速申请谷歌SSL免费证书

&#x1f525;httpsok-快速申请谷歌SSL免费证书 使用场景&#xff1a; 部署CDN证书、OSS云存储证书证书类型&#xff1a; 单域名 多域名 通配符域名 混合域名证书厂商&#xff1a; ZeroSSL Lets Encrypt Google证书加密类型&#xff1a; ECC、 RSA 一、证书管理 进入 证书管…

基于Python的LSTM网络实现单特征预测回归任务(pytorch版)

一、数据集 自建数据集--【load.xlsx】。包含2列&#xff1a; date列&#xff08;时间列&#xff0c;记录2022年6月2日起始至2023年12月31日为止&#xff0c;日度数据&#xff09;price列&#xff08;价格列&#xff0c;记录日度数据对应的某品牌衣服的价格&#xff0c;浮点数…

uni-app实现下拉刷新

业务逻辑如下&#xff1a; 1.在滚动容器中加入refresher-enabled属性&#xff0c;表示为开启下拉刷新 2.监听事件&#xff0c;添加refresherrefresh事件 3.在事件监听函数中加载数据 4.关闭动画&#xff0c;添加refresher-triggered属性&#xff0c;在数据请求前开启刷新动画…

戴尔 latitude 3490使用体验

最近体验了一下戴尔 latitude 3490&#xff0c;又体验了一下戴尔 latitude 3420.再把戴尔最近的主流轻薄本、当下主流轻薄本做对比. 感觉3490比3420好用&#xff0c;要流畅一些&#xff0c;但是有点重&#xff0c;整体设计感不如3420。 戴尔 Latitude 14 3000系列 3490(i5 82…