秒杀场景下的业务梳理——Redis分布式锁的优化

秒杀场景下的业务梳理——Redis分布式锁的优化

随着互联网的快速发展,商品秒杀的场景我们并不少见;秒杀是一种供不应求的,高并发的场景,它里面包含了很多技术点,掌握了其中的技术点,虽不一定能让你面试立马成功,但那也必是一个闪耀的点!

前言

假设我们现在有一个商城系统,里面上线了一个商品秒杀的模块,那么这个模块我们要怎么设计呢?

秒杀模块又会有哪些不同的需求呢?

全局唯一 ID

商品秒杀本质上其实还是商品购买,所以我们需要准备一张订单表来记录对应的秒杀订单。

这里就涉及到了一个订单 id 的问题了,我们是否可以像其他表一样使用数据库自身的自增 id 呢?

数据库自增 id 的缺点

订单表如果使用数据库自增 id ,则会存在一些问题:

  1. id 的规律太明显了 因为我们的订单 id 是需要回显给用户查看的,如果是 id 规律太明显的话,会暴露一些信息,比如第一天下单的 id = 10 , 第二天下单的 id = 11,这就说明这两单之间根本没有其他用户下单
  2. 受单表数据量的限制 在高并发场景下,产生上百万个订单都是有可能的,而我们都知道 MySQL 的单张表根本不可能容纳这么多数据(性能等原因的限制);如果是将单表拆成多表,还是用数据库自增 id 的话,就存在了订单 id 重复的情况了,很显然这是业务不允许的。

基于以上两个问题,我们可以知道订单表的 id 需要是一个全局唯一的 ID,而且还不能存在明显的规律。

全局 ID 生成器

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

image.png

这里我们思考一下是否可以用 Redis 中的自增计数来作为全局 id 生成器呢?

能不能主要是看它是否满足上述 5 个条件:

  1. 唯一性,每个订单都是来 Redis 这里生成订单 id 的,所以唯一性可以保证
  2. 高可用,Redis 可以由主从、集群等模式保证可用性
  3. 高性能,Redis 是基于内存的,本来就是以性能自称的
  4. 递增性,increment 本来就是递增的
  5. 安全性。。。这个就麻烦了点了,因为 Redis 的 increment 也是递增的,规律太明显了。。。

综上,Redis 的 increment 并不能满足安全性,所以我们不能单纯使用它来做全局 id 生成器。

但是——

我们可以使用它,再和其他东西拼接起来~

举个栗子:

image.png

ID的组成部分:

  1. 符号位:1bit,永远为0
  2. 时间戳:31bit,以秒为单位,可以使用69年
  3. 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

上面的时间戳就是用来增加复杂性的

下面给出代码样例:

public class RedisIdWorker {/*** 开始时间戳*/private static final long BEGIN_TIMESTAMP = 1640995200L;/*** 序列号的位数*/private static final int COUNT_BITS = 32;
​private StringRedisTemplate stringRedisTemplate;
​public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}
​public long nextId(String keyPrefix) {// 1.生成时间戳LocalDateTime now = LocalDateTime.now();long nowSecond = now.toEpochSecond(ZoneOffset.UTC);long timestamp = nowSecond - BEGIN_TIMESTAMP;
​// 2.生成序列号// 2.1.获取当前日期,精确到天String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));// 2.2.自增长// 每天一个keylong count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
​// 3.拼接并返回return timestamp << COUNT_BITS | count;}
}

Redis自增ID策略:

  1. 每天一个key,方便统计订单量
  2. ID构造是 时间戳 + 计数器

扩展

全局唯一ID生成策略:

  1. UUID
  2. Redis自增(需要额外拼接)
  3. snowflake算法
  4. 数据库自增

超卖问题的产生

动画.gif

解决方案

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:

锁有两种:

一,悲观锁: 认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁;

二,乐观锁: 认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。

如果没有修改则认为是安全的,自己才更新数据。 如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。

乐观锁的两种实现

下面介绍乐观锁的两种实现:

第一种,添加版本号:

每扣减一次就更改一下版本号,每次进行扣减之前需要查询一下版本号,只有在扣减时的版本号和之前的版本号相同时,才进行扣减。

动画2.gif

第二种,CAS法

因为每扣减一次,库存量都会发生改变的,所以我们完全可以用库存量来做标志,标志当前库存量是否被其他线程更改过(在这种情况下,库存量的功能和版本号类似)

动画3.gif

下面给出 CAS 法扣除库存时,针对超卖问题的解决方案:

   // 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1") // set stock = stock - 1.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0.update();

请注意上述的 CAS 判断有所优化了的,并不是判断刚查询的库存和扣除时的库存是否相等,而是判断当前库存是否大于 0。

因为 判断刚查询的库存和扣除时的库存是否相等会出现问题:假如多个线程都判断到不相等了,那它们都停止了扣减,这时候就会出现没办法买完了。

判断当前库存是否大于 0,则可以很好地解决上述问题!

一人一单的需求

一般来说秒杀的商品都是优惠力度很大的,所以可能存在一种需求——平台只允许一个用户购买一个商品。

对于秒杀场景下的这种需求,我们应该怎么去设计呢?

很明显,我们需要在执行扣除库存的操作之前,先去查查数据库是否已经有了该用户的订单了;如果有了,说明该用户已经下单过了,不能再购买;如果没有,则执行扣除操作并生成订单。

// 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 判断是否存在
if (count > 0) {// 用户已经购买过了return Result.fail("用户已经购买过一次!");
}
​
// 扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1") // set stock = stock - 1.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0.update();

并发安全问题

因为上述的实现是分成两步的:

  1. 判断当前用户在数据库中并没有订单
  2. 执行扣除操作,并生成订单

也正因为是分成了两步,所以才引发了线程安全问题: 可以是同一个用户的多个请求线程都同时判断没有订单,后续则大家都执行了扣除操作。

要解决这个问题,也很简单,只要让这两步串行执行即可,也就是加锁!

在方法头上加 synchronized

很显然这种会锁住整个方法,锁的范围太大了,而且会对所有请求线程作出限制;而我们的需求只是同一个用户的请求线程串行就可以了;显然有些大材小用了~

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {// 一人一单Long userId = UserHolder.getUser().getId// 查询订单int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();// 判断是否存在if (count > 0) {// 用户已经购买过了return Result.fail("用户已经购买过一次!");// 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1") // set stock = stock - 1.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0.update();if (!success) {// 扣减失败return Result.fail("库存不足!");// 创建订单VoucherOrder voucherOrder = new VoucherOrder();.....return Result.ok(orderId);
}
锁住同一用户 id 的 String 对象
@Transactional
public Result createVoucherOrder(Long voucherId) {// 一人一单Long userId = UserHolder.getUser().getId// 锁住同一用户 id 的 String 对象synchronized (userId.toString().intern()) {// 查询订单int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();// 判断是否存在......// 扣减库存......// 创建订单......}return Result.ok(orderId);
}

上述方法开启了事务,但是synchronized (userId.toString().intern())锁住的却不是整个方法(先释放锁,再提交事务,写入订单),那就存在一个问题——假如一个线程的事务还没提交(也就是还没写入订单),这时候其他线程来了却可以获得锁,它判断数据库中订单为0 ,又可以再次创建订单。。。。

为了解决这个问题,我们需要先提交事务,再释放锁:

 // 锁住同一用户 id 的 String 对象synchronized (userId.toString().intern()) {......createVoucherOrder(voucherId);......}
​
@Transactional
public Result createVoucherOrder(Long voucherId) {// 一人一单Long userId = UserHolder.getUser().getId// 查询订单int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();// 判断是否存在......// 扣减库存......// 创建订单......return Result.ok(orderId);
}

集群模式下的并发安全问题

刚刚讨论的那些都默认是单机结点的,可是现在如果放在了集群模式下的话就会出现一下问题。

刚刚的加锁已经解决了单机节点下的线程安全问题,但是却不能解决集群下多节点的线程安全问题:

因为 synchronized 锁的是对应 JVM 内的锁监视器,可是不同的结点有不同的 JVM,不同的 JVM 又有不同的锁监视器,所以刚刚的设计在集群模式下锁住的其实还是不同的对象,即无法解决线程安全问题。

image.png

知道问题产生的原因,我们应该很快就想到了解决办法了:

既然是因为集群导致了锁不同,那我们就重新设计一下,让他们都使用同一把锁即可!

image.png

分布式锁

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

image.png

分布式锁的实现

分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:

MySQLRedisZookeeper
互斥利用mysql本身的互斥锁机制利用setnx这样的互斥命令利用节点的唯一性和有序性实现互斥
高可用
高性能一般一般
安全性断开连接,自动释放锁利用锁超时时间,到期释放临时节点,断开连接自动释放

基于 Redis 的分布式锁

用 Redis 实现分布式锁,主要应用到的是 SETNX key value命令(如果不存在,则设置)

主要要实现两个功能:

  1. 获取锁(设置一个 key)
  2. 释放锁 (删除 key)

基本思想是执行了 SETNX命令的线程获得锁,在完成操作后,需要删除 key,释放锁。

加锁:

@Override
public boolean tryLock(long timeoutSec) {// 获取线程标示String threadId = ID_PREFIX + Thread.currentThread().getId();// 获取锁Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success);
}

释放锁:

@Override
public void unlock() {// 获取线程标示String threadId = ID_PREFIX + Thread.currentThread().getId();// 获取锁中的标示String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);// 释放锁stringRedisTemplate.delete(KEY_PREFIX + name);
}

可是这里会存在一个隐患——假设该线程发生阻塞(或者其他问题),一直不释放锁(删除 key)这可怎么办?

为了解决这个问题,我们需要为 key 设计一个超时时间,让它超时失效;但是这个超时时间的长短却不好确定:

  1. 设置过短,会导致其他线程提前获得锁,引发线程安全问题
  2. 设置过长,线程需要额外等待
锁的误删

动画4.gif

超时时间是一个非常不好把握的东西,因为业务线程的阻塞时间是不可预估的,在极端情况下,它总能阻塞到 lock 超时失效,正如上图中的线程1,锁超时释放了,导致线程2也进来了,这时候 lock 是 线程2的锁了(key 相同,value不同,value一般是线程唯一标识);假设这时候,线程1突然不阻塞了,它要释放锁,如果按照刚刚的代码逻辑的话,它会释放掉线程2的锁;线程2的锁被释放掉之后,又会导致其他线程进来(线程3),如此往复。。。

为了解决这个问题,需要在释放锁时多加一个判断,每个线程只释放自己的锁,不能释放别人的锁!

释放锁

@Override
public void unlock() {// 获取线程标示String threadId = ID_PREFIX + Thread.currentThread().getId();// 获取锁中的标示String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);// 判断标示是否一致if(threadId.equals(id)) {// 释放锁stringRedisTemplate.delete(KEY_PREFIX + name);}
}

原子性问题

刚刚我们谈论的释放锁的逻辑:

  1. 判断当前锁是当前线程的锁
  2. 当前线程释放锁

可以看到释放锁是分两步完成的,如果你是对并发比较有感觉的话,应该一下子就知道这里会存在问题了。

分步执行,并发问题!

动画5.gif

假设 线程1 已经判断当前锁是它的锁了,正准备释放锁,可偏偏这时候它阻塞了(可能是 FULL GC 引起的),锁超时失效,线程2来加锁,这时候锁是线程2的了;可是如果线程1这时候醒过来,因为它已经执行了步骤1了的,所以这时候它会直接直接步骤2,释放锁(可是此时的锁不是线程1的了)

其实这就是一个原子性的问题,刚刚释放锁的两步应该是原子的,不可分的!

要使得其满足原子性,则需要在 Redis 中使用 Lua 脚本了。

引入 Lua 脚本保持原子性

lua 脚本:

-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) ==  ARGV[1]) then-- 释放锁 del keyreturn redis.call('del', KEYS[1])
end
return 0

Java 中调用执行:

public class SimpleRedisLock implements ILock {
​private String name;private StringRedisTemplate stringRedisTemplate;
​public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {this.name = name;this.stringRedisTemplate = stringRedisTemplate;}
​private static final String KEY_PREFIX = "lock:";private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;static {UNLOCK_SCRIPT = new DefaultRedisScript<>();UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));UNLOCK_SCRIPT.setResultType(Long.class);}
​@Overridepublic boolean tryLock(long timeoutSec) {// 获取线程标示String threadId = ID_PREFIX + Thread.currentThread().getId();// 获取锁Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success);}
​@Overridepublic void unlock() {// 调用lua脚本stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(KEY_PREFIX + name),ID_PREFIX + Thread.currentThread().getId());}
}
​

到了目前为止,我们设计的 Redis 分布式锁已经是生产可用的,相对完善的分布式锁了。

总结

这一次我们从秒杀场景的业务需求出发,一步步地利用 Redis 设计出一种生产可用的分布式锁:

实现思路:

  1. 利用set nx ex获取锁,并设置过期时间,保存线程标示
  2. 释放锁时先判断线程标示是否与自己一致,一致则删除锁 (Lua 脚本保证原子性)

有哪些特性?

  1. 利用set nx满足互斥性
  2. 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
  3. 利用Redis集群保证高可用和高并发特性

目前还有待完善的点:

  1. 不可重入,同一个线程无法多次获取同一把锁
  2. 不可重试,获取锁只尝试一次就返回false,没有重试机制
  3. 超时释放,锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患(虽然已经解决了误删问题,但是仍然可能存在未知问题)
  4. 主从一致性,如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,在主节点中的锁数据并没有及时同步到从节点中,则会导致其他线程也能获得锁,引发线程安全问题(延迟时间是在毫秒以下的,所以这种情况概率极低)

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

如何恢复最近删除的照片?掌握这些技巧是关键

在手机摄影成为我们日常生活不可或缺的一部分的今天&#xff0c;珍贵的照片记录着我们生活中的重要瞬间。然而&#xff0c;有时候在整理照片时&#xff0c;我们可能会不慎删除一些我们本不想失去的回忆。幸运的是&#xff0c;针对最近删除的照片&#xff0c;掌握一些关键的技巧…

表达式计算

四则运算表达式可以用表达式树表达&#xff0c;如下图后序遍历 现给你一个字符串&#xff0c;代表一个后序遍历形式的四则运算表达式&#xff0c;请计算出表达式的结果&#xff1a;(只输出整数部分) 注&#xff1a;除法只保留整数部分&#xff1b;5/4 1 输入&#xff1a; 一个…

jenkins安装配置,使用Docker发布maven项目全过程记录(2)

2、使用Docker发布Maven项目过程的配置 首先说明&#xff0c;在这里仅介绍我使用Jenkins的发布过程的配置&#xff0c;不涉及Dockerfile、docker-compose.yml文件的内容。 2.1 创建Item 在这里&#xff0c;输入item名称&#xff0c;我使用的Freestyle project&#xff0c;点击…

机器学习实验2——线性回归求解加州房价问题

文章目录 &#x1f9e1;&#x1f9e1;实验内容&#x1f9e1;&#x1f9e1;&#x1f9e1;&#x1f9e1;数据预处理&#x1f9e1;&#x1f9e1;代码缺失值处理特征探索相关性分析文本数据标签编码数值型数据标准化划分数据集 &#x1f9e1;&#x1f9e1;线性回归&#x1f9e1;&am…

CLIP探索笔记

CLIP探索笔记 记录CLIP的流水账&#xff0c;训练和推理是如何完成的&#xff1f; 每一次阅读都有不同的领悟和发现&#xff0c;一些简单的想法。 官方信息 CodePaperBlog只有预测代码模型&#xff0c;没有训练代码 它想干嘛&#xff1f; 他想做一个分类任务&#xff0c;一…

【MySQL故障】主从延迟越来越大

问题背景 研发执行了一个批量更新数据的操作&#xff0c;操作的表是个宽表&#xff0c;大概有90多个字段&#xff0c;数据量有800多w&#xff0c;但是研发是根据ID按行更新。更新开始后&#xff0c;该集群的主从延迟越来越大。 问题现象 1 从库应用binlog基本无落后&#x…

翻毛皮鞋脏了不会清洗怎么办?资深劳保鞋厂家来教你

劳保鞋皮面材质中除了常见的牛皮材质&#xff0c;翻毛皮也是频繁使用的材料&#xff0c;材质不同&#xff0c;在养护上也有区别&#xff0c;今天百华小编来和大家聊聊翻毛皮材质的鞋子清洁方法。 翻毛皮鞋清洗前的准备工作 1.除灰&#xff1a;对于表面灰尘&#xff0c;可以使用…

手机上菜谱记录簿在哪 用备忘录放大看菜谱更清晰

作为一个热爱生活的现代人&#xff0c;我深知健康饮食的重要性。然而&#xff0c;每当我想亲手为自己和家人烹饪美食时&#xff0c;厨艺的不精常常让我望而却步。好在互联网时代&#xff0c;网上搜罗的各式菜谱成了我的救星。但问题是&#xff0c;每次做菜时都得反复查找&#…

JDX图片识别工具1.0版本发布啦

软件介绍 软件核心功能软件界面软件下载软件教程 软件核心功能 工作当中经常处理大量的图片&#xff0c;网上搜索的工具都无法满足需求&#xff0c;因此自己研发批量图片识别工具。 目前还是内测版&#xff0c;1.0版本主要包含如下特性&#xff1a; 批量识别图片&#xff0c…

前端上传大文件使用分片上传

前提:分片上传针对于一些大的文件、普通大小的文件使用element中的上传组件可以实现效果,例如几G的文件就会比较卡,所以这时候就需要用到分片上传~ 前端及后端分片上传笔记 效果:(上传进度展示) 效果:(上传成功的效果展示) 1、 新建一个上传组件 2、使用vue-simple-…

开始学习vue2基础篇(初体验)

一、什么是VUE&#xff08;官网 &#xff1a;https://cn.vuejs.org/&#xff09; 官方给出的概念 &#xff1a;Vue (读音 /vju ː/ &#xff0c;类似于 view) 是一套用 于构建用户界面的前端框架 渐进式的 JavaScript 框架 二、VUE的特点 易用 &#xff1a;基础只需HTML、CSS、…

正则表达式、grep过滤工具、sed基本用法、sed基本操作指令、sed应用案例

1 案例1&#xff1a;使用正则表达式 1.1 问题 本案例要求熟悉正则表达式的编写&#xff0c;完成以下任务&#xff1a; 利用grep或egrep工具练习正则表达式的基本用法 1.2 方案 表&#xff0d;1 基本正则列表 表&#xff0d;2 扩展正则列表 1.3 步骤 实现此案例需要按照如…

微信小程序之WXSS模板样式、页面配置(.json)和网络数据请求

学习的最大理由是想摆脱平庸&#xff0c;早一天就多一份人生的精彩&#xff1b;迟一天就多一天平庸的困扰。各位小伙伴&#xff0c;如果您&#xff1a; 想系统/深入学习某技术知识点… 一个人摸索学习很难坚持&#xff0c;想组团高效学习… 想写博客但无从下手&#xff0c;急需…

CS8370错误,这是由于使用了C# 7.3中不支持的功能

目录 背景: 第一种方法: 第二种办法: 背景: 在敲代码的时候&#xff0c;程序提示报错消息提示:CS8370错误&#xff0c;那么这是什么原因导致的&#xff0c;这是由于使用了C# 7.3中不支持的功能&#xff0c;不支持该功能&#xff0c;那就是版本太低我们就需要升级更高的版本&…

振弦采集仪在桥梁健康监测中的应用与分析

振弦采集仪在桥梁健康监测中的应用与分析 振弦采集仪是一种专门用于桥梁健康监测的设备&#xff0c;通过对桥梁结构的振动信号进行采集和分析&#xff0c;可以实时监测桥梁的结构健康状况并提前预警潜在问题。 振弦采集仪主要通过在桥梁结构上安放振弦传感器&#xff0c;采集…

LiveVideoStack人物专访:深耕多媒体二十载,他怎么看未来的视频云?

抓住已知的&#xff0c;迎面未知的。 编者按&#xff1a; 大模型、降本、出海&#xff0c;是多媒体从业者交流的高频词&#xff0c;内容与交互的需求层出不穷&#xff0c;大模型与AI的演进目不暇接&#xff0c;让增速低走的视频云迎面新的机遇和挑战。作为一个跨越中美多媒体行…

[MySQL]基础的增删改查

目录 1.前置介绍 2.数据库操作 2.1显示当前数据库 2.2创建数据库 2.3 使用数据库 2.4 删除数据库 3.常用数据类型 3.1整型和浮点型 3.2字符串类型 4.表的操作 4.1查看表结构 4.2创建表 4.3删除表 5.重点 5.1操作数据库 5.2常用数据类型 5.3操作表 1.前置介绍 …

微信小程序首页、界面布局、功能简洁(示例三)

微信小程序首页界面布局、页面简洁&#xff0c;功能简单 直接上具体代码&#xff1a; 1、js代码 Page({/*** 页面的初始数据*/data: {imgList: [../../images/demo.jpg, ../../images/demo.jpg, ../../images/demo.jpg],navList: [{src: ../../images/nav1.png,title: 菜单一}…

IntelliJ IDEA 快捷键大全

IntelliJ IDEA 快捷键大全 一、文本编辑二、构建、编译项目 一、文本编辑 CtrlN 查找类 CtrlN 查找文件 CtrlF 查找文本 可以根据需求去选择红框内的选项 CtrlX 剪切 剪切选中文本&#xff0c;如果未选中则剪切当前行CtrlC 复制 复制选中文本&#xff0c;如果未选中则复制当前…

机器学习工程师在人工智能时代的角色

机器学习工程师在人工智能时代的角色 在当今的数字时代&#xff0c;人工智能&#xff08;AI&#xff09;已成为许多行业不可或缺的一部分。从流程自动化到增强客户体验&#xff0c;人工智能具有改变企业的巨大潜力。这一变革性技术的核心是机器学习&#xff0c;该领域专注于开…