分布式应用进行逻辑处理时经常会遇到并发问题。比如一个操作要修改用户的状态,修改状态需要先读出用户的状态, 在内存里进行修改,改完了再存回去。如果这样的操作同时进行了,就会出现并发问题, 因为读取和保存状态这两个操作不是原子的。(Wiki 解释:所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始, 就一直运行到结束,中间不会有任何 context switch 线程切换。)这个时候就要使用到分布式锁来限制程序并发的执行。本博文主要是的介绍分布式锁的原理和应用场景。
锁是一种常用的并发控制机制,用于保证一项资源在任何时候只能被一个线程使用,如果其他线程也要使用同样的资源,必须排队等待上一个线程使用完。
锁指的是程序级别的锁,例如 Java 语言中的 synchronized 和 ReentrantLock 在单应用中使用不会有任何问题, 但如果放到分布式环境下就不适用了,这个时候我们就要使用分布式锁。分布式锁比较好理解就是用于分布式环境下并发控制的一种机制, 用于控制某个资源在同一时刻只能被一个应用所使用。分布式锁比较常见的实现方式有三种:
场景描述:客户端模拟购买商品过程,在Redis中设定库存总数剩100个,多个客户端同时并发购买。
package com.zhuangxiaoyan.springbootredis.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @description
* 最简单的情况,没有加任何的考虑,
* 即使是单体应用,并发情况下数据一致性都有问题
* @param: null
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class NoneController {
@Autowired
StringRedisTemplate template;
@RequestMapping("/buy")
public String index() {
// Redis中存有goods:001号商品,数量为100 相当于是的redis中的get("goods")的操作。
String result = template.opsForValue().get("goods");
// 获取到剩余商品数
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
// 剩余商品数大于0 ,则进行扣减
int realTotal = total - 1;
// 将商品数回写数据库 相当于设置新的值的结果
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败";
}
}
使用Jmeter模拟高并发场景,测试结果如下:
测试结果出现多个用户购买同一商品,发生了数据不一致问题!解决办法:单体应用的情况下,对并发的操作进行加锁操作,保证对数据的操作具有原子性:
package com.zhuangxiaoyan.springbootredis.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @description 单体应用的情况下,对并发的操作进行加锁操作,保证对数据的操作具有原子性
* 1. synchronized
* 2. ReentrantLock
* 这种情况下,不会产生并发问题
* @param: null
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class ReentrantLockController {
// 引入的ReentrantLock 锁机制
Lock lock = new ReentrantLock();
@Autowired
StringRedisTemplate template;
@RequestMapping("/buy")
public String index() {
// 加锁
lock.lock();
try {
// Redis中存有goods:001号商品,数量为100 相当于是的redis中的get("goods")的操作。
String result = template.opsForValue().get("goods");
// 获取到剩余商品数
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
int realTotal = total - 1;
// 将商品数回写数据库 相当于设置新的值的结果
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
} catch (Exception e) {
//解锁
lock.unlock();
} finally {
//解锁
lock.unlock();
}
return "购买商品失败";
}
}
上面解决了单体应用的数据一致性问题,但如果是分布式架构部署呢,架构如下:提供两个服务,端口分别为8001、8002,连接同一个Redis服务,在服务前面有一台Nginx作为负载均衡。两台服务代码相同,只是端口不同。
将8001、8002两个服务启动,每个服务依然用ReentrantLock加锁,用Jmeter做并发测试,发现会出现数据一致性问题!
package com.zhuangxiaoyan.springbootredis.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* @description 面使用redis的set命令来实现加锁
* 1.SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]
* EX seconds − 设置指定的到期时间(以秒为单位)。
* PX milliseconds - 设置指定的到期时间(以毫秒为单位)。
* NX - 仅在键不存在时设置键。
* XX - 只有在键已存在时才设置。
* @param: null
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class RedisLockControllerV1 {
public static final String REDIS_LOCK = "good_lock";
@Autowired
StringRedisTemplate template;
@RequestMapping("/buy")
public String index() {
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value);
// 加锁失败
if (!flag) {
return "抢锁失败!";
}
System.out.println(value + " 抢锁成功");
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败";
} finally {
// 如果在抢到所之后,删除锁之前,发生了异常,锁就无法被释放,所以要在finally处理 template.delete(REDIS_LOCK);
template.delete(REDIS_LOCK);
}
}
}
如果程序在运行期间,部署了微服务jar包的机器突然挂了,代码层面根本就没有走到finally代码块,也就是说在宕机前,锁并没有被删除掉,这样的话,就没办法保证解锁,所以,这里需要对这个key加一个过期时间,Redis中设置过期时间有两种方法:
第一种方法需要单独的一行代码,且并没有与加锁放在同一步操作,所以不具备原子性,也会出问题, 第二种方法在加锁的同时就进行了设置过期时间,所有没有问题,这里采用这种方式。
// 为key加一个过期时间,其余代码不变
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK,value,10L,TimeUnit.SECONDS);
package com.zhuangxiaoyan.springbootredis.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @description 在第四种情况下,如果在程序运行期间,部署了微服务的jar包的机器突然挂了,代码层面根本就没有走到finally代码块
* 没办法保证解锁,所以这个key就没有被删除
* 这里需要对这个key加一个过期时间,设置过期时间有两种方法
* 1. template.expire(REDIS_LOCK,10, TimeUnit.SECONDS);第一种方法需要单独的一行代码,并没有与加锁放在同一步操作,所以不具备原子性,也会出问题
* 2. template.opsForValue().setIfAbsent(REDIS_LOCK, value,10L,TimeUnit.SECONDS);第二种方法在加锁的同时就进行了设置过期时间,所有没有问题
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class RedisLockControllerV2 {
public static final String REDIS_LOCK = "good_lock";
@Autowired
StringRedisTemplate template;
@RequestMapping("/buy")
public String index() {
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
// 为key加一个过期时间 10s
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
// 加锁失败
if (!flag) {
return "抢锁失败!";
}
System.out.println(value + " 抢锁成功");
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败";
} finally {
// 如果在抢到所之后,删除锁之前,发生了异常,锁就无法被释放,所以要在finally处理 template.delete(REDIS_LOCK);
template.delete(REDIS_LOCK);
}
}
}
设置了key的过期时间,解决了key无法删除的问题,但问题又来了,上面设置了key的过期时间为10秒,如果业务逻辑比较复杂,需要调用其他微服务, 处理时间需要15秒(模拟场景,别较真),而当10秒钟过去之后,这个key就过期了,其他请求就又可以设置这个key, 此时如果耗时15秒的请求处理完了,回来继续执行程序,就会把别人设置的key给删除了,这是个很严重的问题!所以,谁上的锁,谁才能删除。
package com.zhuangxiaoyan.springbootredis.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @description
* 在第五种情况下,设置了key的过期时间,解决了key无法删除的问题,但问题又来了
* 我们设置了key的过期时间为10秒,如果我们的业务逻辑比较复杂,需要调用其他微服务,需要15秒
* 10秒钟过去之后,这个key就过期了,其他请求就又可以设置这个key了
* 但是如果耗时的请求处理完了,回来继续执行程序,就会把别人设置的key给删除了,这是个很严重的问题
* 所以,谁上的锁,谁才能删除
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class RedislockControllerV3 {
public static final String REDIS_LOCK = "good_lock";
@Autowired
StringRedisTemplate template;
@RequestMapping("/buy")
public String index() {
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
// 为key加一个过期时间10s
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
// 加锁失败
if (!flag) {
return "抢锁失败!";
}
System.out.println(value + " 抢锁成功");
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
// 如果在此处需要调用其他微服务,处理时间较长。。。
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败";
} finally {
// 谁加的锁,谁才能删除
if (template.opsForValue().get(REDIS_LOCK).equals(value)) {
template.delete(REDIS_LOCK);
}
}
}
}
规定了谁上的锁,谁才能删除,但finally快的判断和del删除操作不是原子操作,并发的时候也会出问题,并发嘛,就是要保证数据的一致性,保证数据的一致性, 最好要保证对数据的操作具有原子性。在redis中的保证原子操作的是
package com.zhuangxiaoyan.springbootredis.controller;
import com.zhuangxiaoyan.springbootredis.utils.RedisUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import redis.clients.jedis.Jedis;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @description 在第六种情况下,规定了谁上的锁,谁才能删除
* 但finally快的判断和del删除操作不是原子操作,并发的时候也会出问题
* 并发就是要保证数据的一致性,保证数据的一致性,最好要保证对数据的操作具有原子性
* @param: null
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class RedisLockControllerV4 {
public static final String REDIS_LOCK = "good_lock";
@Autowired
StringRedisTemplate template;
/**
* @description 使用Lua脚本,进行锁的删除
* @param:
* @date: 2022/4/9 21:56
* @return: java.lang.String
* @author: xjl
*/
@RequestMapping("/buy")
public String index() {
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
// 为key加一个过期时间
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
// 加锁失败
if (!flag) {
return "抢锁失败!";
}
System.out.println(value + " 抢锁成功");
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
// 如果在此处需要调用其他微服务,处理时间较长。。。
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败,服务端口为8001";
} finally {
// 谁加的锁,谁才能删除 使用Lua脚本,进行锁的删除
Jedis jedis = null;
try {
jedis = RedisUtils.getJedis();
String script = "if redis.call('get',KEYS[1]) == ARGV[1] " +
"then " +
"return redis.call('del',KEYS[1]) " +
"else " +
" return 0 " +
"end";
Object eval = jedis.eval(script, Collections.singletonList(REDIS_LOCK), Collections.singletonList(value));
if ("1".equals(eval.toString())) {
System.out.println("-----del redis lock ok....");
} else {
System.out.println("-----del redis lock error ....");
}
} catch (Exception e) {
System.out.println(e.getMessage());
} finally {
if (null != jedis) {
jedis.close();
}
}
}
}
/**
* @description 使用redis事务
* @param:
* @date: 2022/4/9 21:56
* @return: java.lang.String
* @author: xjl
*/
@RequestMapping("/buy2")
public String index2() {
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
// 为key加一个过期时间
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
// 加锁失败
if (!flag) {
return "抢锁失败!";
}
System.out.println(value + " 抢锁成功");
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
// 如果在此处需要调用其他微服务,处理时间较长。。。
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败,服务端口为8001";
} finally {
// 谁加的锁,谁才能删除 ,使用redis事务
while (true) {
template.watch(REDIS_LOCK);
if (template.opsForValue().get(REDIS_LOCK).equalsIgnoreCase(value)) {
template.setEnableTransactionSupport(true);
template.multi();
template.delete(REDIS_LOCK);
List<Object> list = template.exec();
if (list == null) {
continue;
}
}
template.unwatch();
break;
}
}
}
}
规定了谁上的锁,谁才能删除,并且解决了删除操作没有原子性问题。但还没有考虑缓存续命,以及Redis集群部署下,异步复制造成的锁丢失: 主节点没来得及把刚刚set进来这条数据给从节点,就挂了。所以直接上RedLock的Redisson落地实现。
package com.zhuangxiaoyan.springbootredis.controller;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* @description
* 在第六种情况下,规定了谁上的锁,谁才能删除
* 1. 缓存续命
* 2. redis异步复制造成的锁丢失:主节点没来得及把刚刚set进来这条数据给从节点,就挂了
* @param: null
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class RedisLockControllerV5 {
public static final String REDIS_LOCK = "good_lock";
@Autowired
StringRedisTemplate template;
@Autowired
Redisson redisson;
@RequestMapping("/buy")
public String index() {
RLock lock = redisson.getLock(REDIS_LOCK);
lock.lock();
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
// 如果在此处需要调用其他微服务,处理时间较长。。。
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败";
} finally {
// 如果锁依旧在同时还是在被当前线程持有,那就解锁。 如果是其他的线程持有 那就不能释放锁资源
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
系统设计解决方案/1-分布式锁方案/分布式锁解决方案.md · 庄小焱/SeniorArchitect - Gitee.com
文章浏览阅读476次。FhqTreap&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;amp;ThinSpace;\ \ \ \ \ \ \ \,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&a_qfrtpa
文章浏览阅读7.7k次,点赞3次,收藏33次。这里是引用上机一样式body{ border:0px; padding:0px; margin:0px auto; font:12px Tahoma;}div,ul,li,dt,dl { float:left; margin:0px auto; padding:0px;}li{ list-style:none;}a:..._使用jquery快速高效制作网页交互特效上机操作
文章浏览阅读646次。实验环境:VMware虚拟机1台,配置如下:vCPU >=1CMEM >=1GDISK >=20GOS=Linux CentOS7.x网络适配器=NAT配置IP、DNS等,使虚拟机可以连通互联网使用xshell或者secureCRT工具连接实验准备:#cd /etc/yum.repos.d 进入yum配置目录#mkdir backup 创建备份目录#m..._docker 兼用云盘与博客
文章浏览阅读1k次。vnc远程工具不知道大家听说过没有,它的中文名叫虚拟网络控制台,这是一款优秀的远程控制工具软件。相信大家看到远程控制软件应该已经猜出来了这款软件到底是干嘛的了吧。大家找到过很好用过的vnc远程工具吗?今天来给大家介绍一款非常好用的vnc远程工具吧。教你怎么进行vnc远程工具的下载和安装。使用工具:IIS7服务器管理工具这款软件的vnc功能其实已经是很齐全的了,操作使用非常的方便。它不仅可以批量打开链接,也可以群控操作,操作一台等于同时操作N台。这可以说是非常方便好用了。强烈推荐一下。IIS7服务器管理_亲这是远程工具下载安装一下,设备代码和密码发我,远程工具不要关闭给您远程。:htt
文章浏览阅读284次。从程序员的角度看linux和windows的对比:一 系统架构的对比1 内核(1) 内核的弹性内核内核的弹性linux的内核表现出了高度的可配置性和独立性,主要是完成:io驱动设备管理,tcp/ip,以及任务调度.linux的标准内核发布版本有40~50mb,而我现在在一些评估板上试验的嵌入式linux系统(使用arm或m68k系列的cpu)只用到了2mb,同样实现了网络功能和完整的任务调度,这使..._linux和vs的区别
文章浏览阅读5k次,点赞12次,收藏34次。详细记录用法,清晰明了!np.expand_dims(a, axis = 0 )np.expand_dims(a, axis = 1 )np.expand_dims(a, axis = 2 )np.expand_dims(a, axis = 3 )np.expand_dims(a, axis = -1 )_np.expand
文章浏览阅读4.6k次,点赞8次,收藏54次。对于程序员来说,使用MarkDown编辑器远远比使用word等编辑器好,统一自然、代码高亮等特点已经成为了程序员的必备编辑器。那么如何使自己开发的页面也具备markdown编辑器的功能呢?下载与安装1.下载开源的编辑器插件 Markdown.zip方法一:官网下载:https://pandao.github.io/editor.md/ —速度有点慢,有点难下载方法二:百度网盘 https://pan.baidu.com/s/1JzoQR17zopU9t7Jq7XWvvg 提取码:qdlz —推_dw如何添加类似markdown的编辑器
文章浏览阅读2.5k次,点赞3次,收藏13次。3-7-6银行业务队列简单模拟设某银行有A、B两个业务窗口,且处理业务的速度不一样,其中A窗口处理速度是B窗口的2倍 —— 即当A窗口每处理完2个顾客时,B窗口处理完1个顾客。给定到达银行的顾客序列,请按业务完成的顺序输出顾客序列。假定不考虑顾客先后到达的时间间隔,并且当不同窗口同时处理完2个顾客时,A窗口顾客优先输出。输入格式:输入为一行正整数,其中第1个数字N(≤1000)为顾..._设某银行有a、b两个业务窗口,且处理业务的速度不一样,其中a窗口处理速度是b窗口的
文章浏览阅读78次。 故事的发生起于,由于老板强烈要求app在iPhone6和5有一样的工具栏,然后前端妹子用@media为iPhone6和Plus做了样式适配。然后问题来了,竟然奇葩的发现@media样式只对iPhone4和5起了作用,然后在6和6S的样式效果和5是一样的,奇了怪了! 然后我去查找原因,无意中去获取设备屏幕宽高时发现了这神奇的现象:CGRect screenBounds = [[UI..._ios的闪屏适配
文章浏览阅读173次。文章目录 1.核心配置文件 1.1环境配置 1.2属性 1.3类型别名1.核心配置文件..._ioc对象创建与使用 springboot
文章浏览阅读2.2w次,点赞11次,收藏86次。在空间关系里面,点与点之间的关系是最简单的(要么重合,要么分离),而且实际上真实世界的物理空间里面,是没有点这个东西的……那是一维空间的玩意儿。从更高层的抽象中对概念进行描述,是科研的重要方法论,所以在空间分析里面,大部分空间实体都被抽象成为了点——仅表示位置,没有大小粗细范围一说。其实说了这么多年的“空间”分析,这个空间的概念,从狭义上说通常指的是地理空间,然后根据地理学第一定律_空间权重
文章浏览阅读5.4k次,点赞4次,收藏3次。1 、执行:【open ~/.zshrc 】open ~/.zshrc2 、如果 提示文件不存在,则执行:【vim ~/.zshrc 】新建一个新文件。vim ~/.zshrc3 、再执行【open ~/.bash_profile 】open ~/.bash_profile4 、把 bash_profile 中的内容copy到 zshrc 文件中,保存:【:wq回车】。export PUB_HOSTED_URL=https://pub.flutter-io.cnexport FLUTTER__command not found: flutter