生活场景
星期天的上午,布布和一二在家里畅谈人生,突然觉得生活太乏味,一二提出要去吃一碗新鲜的麻辣烫。
于是他们来到一家当地有名的麻辣烫店。
刚进门,店里摆放了一个自助取餐具柜。
当时排队人数较多,如果大家都从取餐柜中拿餐盘,每个人都需要开一次柜取一个。
这样开柜门、关柜门都需要消耗不必要的时间。但是当我们把餐具摆到外边,大家随手可以获取。
这样可以避免频繁的开关柜门,从而节省很多时间。过程描述如下:
- 顾客从外放的餐具处拿餐具(缓存)
- 如果外放餐具没有,服务员从餐柜拿出一摞补充到原来位置(从磁盘加载到内存,避免频繁I/O)
- 如果服务员比较忙,顾客自助从取餐柜拿餐具(磁盘)
缓存设计模型
先不深入理解缓存设计的深层原理,我们先从这个生活例子抽象出一个简易的缓存
设计模型:
在计算机领域,关于磁盘、内存、缓存、I/O的概念
- 磁盘(Disk):
- 是什么: 就像电脑的大型存储仓库,用于持久保存数据。
- 怎么玩: 数据在磁盘上存储并可以
长期保存
,即使电脑关闭也不会丢失。 - 举栗子: 就像一个大衣柜,你可以把不经常用的东西放在那里,随时取出。
- 内存(Memory):
- 是什么: 类似于电脑的
短期记忆
,用于存储当前正在使用的程序和数据。 - 怎么玩: 数据在内存中存储,电脑运行时会迅速读取和写入,但一旦关闭电脑,内存就会被清空。
- 举栗子: 就像桌子上的工作空间,学习工作时放东西,工作完把东西收拾带走。
- 是什么: 类似于电脑的
- 缓存(Cache):
- 是什么: 一种更小但更快速的存储,用于
临时存储
经常访问的数据,以提高计算机的性能。 - 怎么玩: 缓存存储了最常用的数据,使得计算机能够更快速地获取信息。
- 举栗子: 就像你桌子上的备忘录,记录着你最常用的信息,不用每次都去找。
- 是什么: 一种更小但更快速的存储,用于
- I/O(Input/Output):
- 是什么: 涉及计算机与外部世界之间的数据传输,包括
输入(从外部获取数据)和输出(向外部发送数据)
。 - 怎么玩: 例如,从磁盘读取文件(输入)或将数据显示在屏幕上(输出)。
- 举栗子: 就像你从书上读取信息(输入),或者将你的想法写在纸上(输出)。
- 是什么: 涉及计算机与外部世界之间的数据传输,包括
小结
综上,我们对缓存
有了概念上的理解。在实战项目中,不同的系统承载的流量有所不同,峰值状态:几万、几十万、几百万、亿级流量都有可能。
缓存设计就是是为了避免频繁I/O、提高吞吐量、增强系统性能
。
接下来,我们逐一探究实际项目实战链路中,都会用到那些缓存设计来提高系统性能?
传统缓存设计
传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库,如图:
存在下面的问题:
- 请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈
- Redis缓存失效时,会对数据库产生冲击
多级缓存
什么是多级缓存?
多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Server端压力,提升服务性能。
如图,是多级缓存架构基本组成:
我们简单描述下,当请求进入系统后的工作流程:
- 浏览器访问静态资源时,优先读取
浏览器本地缓存
- 访问非静态资源(ajax查询数据)时,访问服务端
- 请求到达Nginx后,优先读取
Nginx本地缓存
- 如果Nginx本地缓存未命中,则去直接查询
Redis
(不经过Tomcat) - 如果Redis查询未命中,请求进入Tomcat后,优先查询
JVM进程缓存
- 如果JVM进程缓存未命中,则查询数据库
标注:
在多级缓存架构中,基于OpenResty框架Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑,因此这样的nginx服务不再是一个反向代理服务器
,而是一个编写业务的Web服务器
。因此这样的业务Nginx服务也需要搭建集群来提高并发,再有专门的nginx服务来做反向代理
小结
可见,全链路多级缓存有几个关键点:
- 浏览器本地缓存
- nginx本地缓存(运用OpenResty中Nginx+Lua编程)
- Redis缓存
- JVM进程本地缓存
本文重点聊聊JVM进程缓存和Redis缓存应用
缓存对系统性能影响至关重要,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。
我们把缓存分为两类:
- 分布式缓存,例如Redis:
- 优点:存储容量更大、可靠性更好、可以在集群间共享
- 缺点:访问缓存有网络开销
- 场景:缓存数据量较大、可靠性要求较高、需要在集群间共享
- 进程本地缓存,例如ConcurrentHashMap、Guava Cache:
- 优点:读取本地内存,没有网络开销,速度更快
- 缺点:存储容量有限、可靠性较低、无法共享
- 场景:性能要求较高,缓存数据量较小
今天我们利用Caffeine框架来实现JVM进程缓存。
JVM进程缓存(本地缓存)
JVM进程缓存 Caffeine 介绍
Caffeine 是一个基于Java8开发的,提供了近乎最佳命中率的高性能本地缓存
库。
目前Spring内部的缓存使用的就是Caffeine。
官方介绍
性能测试报告对比(官方测试数据,当然不同机器结果也不同):
简单学习Caffeine使用:
- 导入依赖
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
- 基本用法
@Test
void testBasicOps() {
// 创建缓存对象
Cache<String, String> cache =
Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(10)) // 设置缓存有效期为 10 秒,从最后一次写入开始计时
.build();
// 存数据
cache.put("code", "码易有道");
// 取数据,不存在则返回null
String val = cache.getIfPresent("code_01"); //null
// 取数据,不存在则去数据库查询
String dbVal = cache.get("ping", key -> {
// 这里可以查询数据库根据 key查询value
return "pong";
});
}
- 真实案例编写
利用Caffeine实现下列需求:
- 给根据id查询商品的业务添加缓存,缓存未命中时查询数据库
- 缓存初始大小为100
- 缓存上限为10000
配置config
@Configuration
public class CaffeineConfig {
@Bean
public Cache<Long, Item> itemCache(){
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000)
.build();
}
}
业务逻辑:
@GetMapping("/{id}")
public Item findById(@PathVariable("id") Long id) {
//查本地缓存 --> 如果没有,查数据库: 状态不为:2 ,且查询条件是ID等于指定的键值,返回一个结果
return itemCache.get(id, key -> itemService.query()
.ne("status", 2)
.eq("id", key)
.one()
);
}
演示结果:
访问:http://localhost:8081/item/10002,返回正确结果
第一次访问,打印出SQL,说明查询了数据库:
21:28:49:535 DEBUG 3332 --- [nio-8081-exec-1] c.asdc.item.mapper.ItemMapper.selectOne : ==> Preparing: SELECT id,name,title,price,image,category,brand,spec,status,create_time,update_time FROM tb_item WHERE (status <> ? AND id = ?)
21:28:49:536 DEBUG 3332 --- [nio-8081-exec-1] c.asdc.item.mapper.ItemMapper.selectOne : ==> Parameters: 2(Integer), 10002(Long)
21:28:49:544 DEBUG 3332 --- [nio-8081-exec-1] c.asdc.item.mapper.ItemMapper.selectOne : <== Total: 1
第二次访问,未打印出SQL,说明直接查询的本地缓存。
因此,利用Caffeine实现缓存功能生效。
Redis缓存
redis 到底有多快?
根据官方数据,Redis 的 QPS 可以达到约 100000(每秒请求数),感兴趣的可参考:
官方Redis benchmark
以下摘自官网:
With high-end configurations, the number of client connections is also an important factor. Being based on epoll/kqueue, the Redis event loop is quite scalable. Redis has already been benchmarked at more than 60000 connections, and was still able to sustain 50000 q/s in these conditions. As a rule of thumb, an instance with 30000 connections can only process half the throughput achievable with 100 connections. Here is an example showing the throughput of a Redis instance per number of connections:
客户端连接数压测报告:
X轴:客户端连接数,Y轴:QPS
生产实践中如何使用redis做缓存?
基本流程:
程序实现:
1、导入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2、配置连接
spring:
redis:
host: your_ip
database: 1
username: your_username
password: your_password
3、代码编写:
@Autowired
private StringRedisTemplate redisTemplate;
//JSON 转化
private static final ObjectMapper MAPPER = new ObjectMapper();
@GetMapping("/redis/{id}")
public String queryRedisItemData(@PathVariable("id") Long id) throws JsonProcessingException {
// 查询Redis缓存
String cachedResult = redisTemplate.opsForValue().get("item:id:" + id);
if (Strings.isNotBlank(cachedResult)) {
System.out.println("从Redis缓存中获取数据:" + cachedResult);
return cachedResult;
} else {
// 1.查询数据库
Item item = itemService.query().ne("status", 2).eq("id", id).one();
//转化成json
String dbResult = MAPPER.writeValueAsString(item);
System.out.println("从数据库中获取数据:" + dbResult);
// 将查询结果存入缓存
redisTemplate.opsForValue().set("item:id:" + id, dbResult);
return dbResult;
}
}
4、演示效果:
4.1. 测试前:redis中没有对应key:item:id:10002
4.2. 访问:http://localhost:8081/item/10002,返回正确结果
4.3. 查看控制台SQL打印情况,显然第一次访问查询了数据库
23:25:33:162 DEBUG 20032 --- [nio-8081-exec-1] c.asdc.item.mapper.ItemMapper.selectOne : ==> Preparing: SELECT id,name,title,price,image,category,brand,spec,status,create_time,update_time FROM tb_item WHERE (status <> ? AND id = ?)
23:25:33:163 DEBUG 20032 --- [nio-8081-exec-1] c.asdc.item.mapper.ItemMapper.selectOne : ==> Parameters: 2(Integer), 10002(Long)
23:25:33:171 DEBUG 20032 --- [nio-8081-exec-1] c.asdc.item.mapper.ItemMapper.selectOne : <== Total: 1
从数据库中获取数据:{"id":10002,"name":"脱脂牛奶","title":"安佳脱脂牛奶 新西兰进口轻欣脱脂250ml*24整箱装*2","price":68600,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t25552/261/1180671662/383855/33da8faa/5b8cf792Neda8550c.jpg!q70.jpg.webp","category":"牛奶","brand":"安佳","spec":"{\"数量\": 24}","status":1,"createTime":1706198400000,"updateTime":1706198400000,"stock":null,"sold":null}
4.4. 查看当前redis库db1中数据
4.5. 再次访问:http://localhost:8081/item/10002,控制台打印:
从Redis缓存中获取数据:{"id":10002,"name":"脱脂牛奶","title":"安佳脱脂牛奶 新西兰进口轻欣脱脂250ml*24整箱装*2","price":68600,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t25552/261/1180671662/383855/33da8faa/5b8cf792Neda8550c.jpg!q70.jpg.webp","category":"牛奶","brand":"安佳","spec":"{\"数量\": 24}","status":1,"createTime":1706198400000,"updateTime":1706198400000,"stock":null,"sold":null}
显然,本次查询走redis缓存,验证通过。这也印证了上边的流程。
缓存预热怎么做?
通常,Redis缓存会面临冷启动问题:
冷启动:服务刚刚启动时,Redis中并没有缓存,如果所有业务数据都在第一次查询时添加缓存,可能会给数据库带来较大压力。
缓存预热:在实际开发中,为避免上述问题,我们一般会利用大数据统计用户访问的热点数据
,在项目启动时将这些热点数据提前查询并保存到Redis中。这个过程成为缓存预热。
方案一、在项目启动时初始化Bean时候完成
这里我们利用InitializingBean接口来实现,因为InitializingBean可以在对象被Spring创建并且成员变量全部注入后执行。
这里我们是测试数据(模拟热点数据),全量加载到redis中。
程序实现:
@Component
public class RedisHandler implements InitializingBean {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private IItemService itemService;
//JSON 转化
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public void afterPropertiesSet() throws Exception {
// 初始化缓存
// 1.从数据库获取热点数据
List<Item> itemList = itemService.list();
// 2.放入缓存
for (Item item : itemList) {
// item序列化为JSON
String json = MAPPER.writeValueAsString(item);
// 存入redis
redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
}
}
}
启动并测试:
23:42:08:477 DEBUG 13200 --- [ main] c.a.item.mapper.ItemMapper.selectList : ==> Preparing: SELECT id,name,title,price,image,category,brand,spec,status,create_time,update_time FROM tb_item
23:42:08:507 DEBUG 13200 --- [ main] c.a.item.mapper.ItemMapper.selectList : ==> Parameters:
23:42:08:534 DEBUG 13200 --- [ main] c.a.item.mapper.ItemMapper.selectList : <== Total: 5
查看redis中,key:item:10001~10005 ,均正常保存。代表缓存预热已实现。
方案二、在项目启动完成后,通过访问接口同步
@GetMapping("/loadRedis")
public String loadRedis() throws JsonProcessingException {
// 初始化缓存
try {
// 1.从数据库查询
List<Item> itemList = itemService.list();
// 2.放入缓存
for (Item item : itemList) {
// item序列化为JSON
String json = MAPPER.writeValueAsString(item);
// 存入redis
redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
}
} catch (Exception e) {
return "loadRedis failed!";
}
return "loadRedis sucess!";
}
缓存数据同步如何做?
数据同步策略
缓存数据同步的常见方式有三种:
设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新
- 优势:简单、方便
- 缺点:时效性差,缓存过期之前可能不一致
- 场景:更新频率较低,时效性要求低的业务
同步双写:在修改数据库的同时,直接修改缓存
- 优势:时效性强,缓存与数据库强一致
- 缺点:有代码侵入,耦合度高;
- 场景:对一致性、时效性要求较高的缓存数据
异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据
- 优势:低耦合,可以同时通知多个缓存服务
- 缺点:时效性一般,可能存在中间不一致状态
- 场景:时效性要求一般,有多个服务需要同步
异步通知方案:
1)基于MQ异步消息
基本流程如下:
缓存同步流程描述:
- 业务服务完成对数据的修改后,只需要发送一条消息到MQ中。
- 缓存服务监听MQ消息,然后完成对缓存的更新
缺点:依然有少量的代码侵入。
2)基于Canal的通知
Canal是阿里巴巴旗下的一款开源项目,基于Java开发。基于数据库增量日志解析,提供增量数据
订阅和消费。GitHub的地址:https://github.com/alibaba/canal
Canal是基于mysql的主从同步来实现的,MySQL主从同步的原理如下:
- MySQL master 将数据变更写入二进制日志( binary log),其中记录的数据叫做binary log events
- MySQL slave 将 master 的 binary log events拷贝到它的中继日志(relay log)
- MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据
而Canal类似于MySQL的一个slave节点,从而监听master的binary log变化。
再把得到的变化信息通知给Canal对应的客户端,完成对数据库的同步。
本次我们利用Canal同步数据库和redis.
基本流程如下:
流程描述:
- 业务数据发生变更
- 更新数据库
- Canal监听Mysql binlog 变化
- 通知缓存服务更新
- 更新缓存
程序实现:
1、引入依赖
<dependency>
<groupId>top.javatool</groupId>
<artifactId>canal-spring-boot-starter</artifactId>
<version>1.2.1-RELEASE</version>
</dependency>
2、Mysql主从核心配置
$ vim /etc/my.cnf
[mysqld]
log-bin=/data/mysql/mysql-bin #设置binary log文件的存放地址和文件名,叫做mysql-bin
binlog-do-db=mydb # 指定对哪个database记录binary log events,这里记录mydb这个库
server-id=1000 # 配置 MySQL replaction 需要定义,不要和 canal 的 slaveId 重复
重启mysql
[root@hcss-ecs-143a ~]# service mysql restart
Shutting down MySQL............ [ OK ]
Starting MySQL.. [ OK ]
检查主从同步状态和集群名称:
3、yml配置
canal:
destination: mydb # canal的集群名字,要与安装canal时设置的名称一致
server: 1.XXX.XXX.47:11111 # canal服务地址
4、Canal 安装和配置
安装步骤请自行查阅,这里只提供核心配置
canal.properties 配置
$ vim /opt/canal/conf/canal.properties
canal.port = 11111
canal.instance.tsdb.dbUsername = canal
canal.instance.tsdb.dbPassword = canal
#################################################
######### destinations #############
#################################################
canal.destinations = mydb
instance.properties 配置
安装后会在/opt/canal/conf/example 下 有instance.properties 配置。 参考修改:
# table regex
canal.instance.filter.regex=mydb\\..*
最终配置完成:
5、编写监听器
通过实现EntryHandler<T>
接口编写监听器,监听Canal消息。注意两点:
- 实现类通过
@CanalTable("tb_item")
指定监听的表信息 - EntryHandler的泛型是与表对应的实体类
@CanalTable("tb_item")
@Component
public class ItemHandler implements EntryHandler<Item> {
@Autowired
private RedisHandler redisHandler;
@Autowired
private Cache<Long, Item> itemCache;
@Override
public void insert(Item item) {
// 写数据到JVM进程缓存
itemCache.put(item.getId(), item);
// 写数据到redis
redisHandler.saveItem(item);
}
@Override
public void update(Item before, Item after) {
// 写数据到JVM进程缓存
itemCache.put(after.getId(), after);
// 写数据到redis
redisHandler.saveItem(after);
}
@Override
public void delete(Item item) {
// 删除数据到JVM进程缓存
itemCache.invalidate(item.getId());
// 删除数据到redis
redisHandler.deleteItemById(item.getId());
}
}
6、演示效果 比如:我们将id = 10002 脱脂牛奶 价格(price)变为998.
修改前:
mysql> select t.id,t.name,t.price from mydb.tb_item t where t.id = '10002' ;
+-------+--------------+-------+
| id | name | price |
+-------+--------------+-------+
| 10002 | 脱脂牛奶 | 68600 |
+-------+--------------+-------+
1 row in set (0.00 sec)
执行修改: 访问:http://localhost:8081/item 更新id=10002 对应价格998. 请求参数:
{
"id":10002,
"name":"脱脂牛奶",
"title":"安佳脱脂牛奶 新西兰进口轻欣脱脂250ml*24整箱装*2",
"price":998,
"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t25552/261/1180671662/383855/33da8faa/5b8cf792Neda8550c.jpg!q70.jpg.webp",
"category":"牛奶",
"brand":"安佳",
"spec":"{\"数量\": 30}",
"status":1,
"createTime":"2024-01-25T16:00:00.000+00:00",
"updateTime":"2024-01-25T16:00:00.000+00:00",
"stock":99999,
"sold":54981
}
修改后:
查看数据库:
mysql> select t.id,t.name,t.price from mydb.tb_item t where t.id = '10002' ;
+-------+--------------+-------+
| id | name | price |
+-------+--------------+-------+
| 10002 | 脱脂牛奶 | 998 |
+-------+--------------+-------+
1 row in set (0.00 sec)
查看redis:
查看控制台:
11:14:28:980 INFO 19916 --- [l-client-thread] t.j.c.client.client.AbstractCanalClient : 获取消息 Message[id=2,entries=[header {
version: 1
logfileName: "mysql-bin.000005"
logfileOffset: 978
serverId: 1000
serverenCode: "UTF-8"
executeTime: 1706325266000
sourceType: MYSQL
schemaName: ""
tableName: ""
eventLength: 31
}
说明Canal同步缓存数据生效,验证通过。
基于OpenResty的Nginx本地缓存
OpenResty为Nginx提供了shard dict的功能,可以在nginx的多个worker之间共享数据,实现缓存功能。
这里粗略给出OpenResty安装后的目录结构:
从目录结构可以看到:OpenResty 内置Nginx + Lua模块
1)定义共享字典,在nginx.conf的http下添加配置:
# 共享字典,也就是本地缓存,名称叫做:item_cache,大小150m
lua_shared_dict item_cache 150m;
2)使用共享字典:
-- 获取本地缓存对象
local item_cache = ngx.shared.item_cache
-- 存储, 指定key、value、过期时间,单位s,默认为0代表永不过期
item_cache:set('key', 'value', 1000)
-- 读取
local val = item_cache:get('key')
3)实现本地缓存查询
修改/usr/local/openresty/lua/item.lua
文件,添加本地缓存逻辑:
设置了缓存过期时间,过期后nginx缓存会自动删除,下次访问即可更新缓存。
这里给商品基本信息设置超时时间为30分钟。因为库存更新频率较高,如果缓存时间过长,可能与数据库差异较大。
完整的item.lua文件:
-- 导入common函数库
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 导入cjson库
local cjson = require('cjson')
-- 导入共享词典,本地缓存
local item_cache = ngx.shared.item_cache
-- 封装查询函数
function read_data(key, expire, path, params)
-- 查询本地缓存
local val = item_cache:get(key)
if not val then
ngx.log(ngx.ERR, "本地缓存查询失败,尝试查询Redis, key: ", key)
-- 查询redis
val = read_redis("1.XXX.XXX.47", 6379, key)
-- 判断查询结果
if not val then
ngx.log(ngx.ERR, "redis查询失败,尝试查询http, key: ", key)
-- redis查询失败,去查询http
val = read_http(path, params)
end
end
-- 查询成功,把数据写入本地缓存
item_cache:set(key, val, expire)
-- 返回数据
return val
end
-- 获取路径参数
local id = ngx.var[1]
-- 查询商品信息
local itemJSON = read_data("item:id:" .. id, 1800 , "/item/" .. id, nil)
-- JSON转化为lua的table
local item = cjson.decode(itemJSON)
-- 把item序列化为json 返回结果
ngx.say(cjson.encode(item))
关于OpenResty 的使用和语法结构,读者请自行查阅。由于笔者属于后端领域,这里暂时简单介绍。
感兴趣的可以深究。
官网地址:https://openresty.org/cn/
总结
当考虑不同的缓存设计方案时,需要根据具体的应用场景和需求来选择。
这里给出项目链路上关于缓存设计的应用场景和建议:
- 浏览器缓存:
- 应用场景: 适用于静态资源如图片、CSS和JavaScript等的缓存,以减少服务器负载和加快页面加载速度。
- 建议: 使用HTTP标头控制缓存策略,如Cache-Control和Expires,确保资源在客户端浏览器上被有效地缓存,减少对服务器的请求次数。
- 基于OpenResty的Nginx本地缓存:
- 应用场景: 适用于对频繁请求的动态数据进行本地缓存,如API响应数据、网页片段等。
- 建议: 使用OpenResty结合Nginx的本地缓存功能,通过Lua脚本实现对特定请求响应的本地缓存,可以有效减少后端服务器的负载和提高响应速度。
- Redis缓存:
- 应用场景: 适用于需要分布式缓存、高并发读写、数据结构丰富(如哈希、列表、集合等)的场景,如会话管理、页面片段缓存、数据缓存等。
- 建议: 使用Redis作为分布式缓存,结合其丰富的数据结构和高性能特性,可以实现高效的缓存管理和数据处理。
- JVM进程缓存:
- 应用场景: 适用于对应用程序内部数据进行缓存,如对象缓存、方法结果缓存等。
- 建议: 使用基于内存的缓存库,如Caffeine、ConcurrentHashMap、Guava Cache等,将需要频繁访问的数据缓存在JVM进程中,以加快数据访问速度,并减少对外部存储的依赖。
综上,缓存设计方案能极大提高应用程序的性能和可扩展性。
需要说明的是:后台系统最好注意对缓存的管理、监控和更新。
此外,我们知道任何的设计都是根据需要的,因为引入新的技术就会带来新的问题,所以也要避免过度设计。
这里讲的是生产实践中的一些思路,可以说多一种选择、多一种可能。
暂无评论内容