商户查询缓存、商户更新缓存(opsForHash、opsForList、ObjectMapper、@Transactional、@PutMapping、RequestParam、装箱拆箱、线程池)
目录
- 一、缓存定义:
- 二、使用Hash结构缓存商户详情信息:
- 三、使用List结构缓存商户类型列表:
- 四、缓存更新:
- 1.删除缓存还是更新缓存?
- 2.如何保证缓存与数据库的操作同时成功或失败?
- 3.先操作缓存还是先操作数据库?
- 4.缓存更新最佳方案:
- 5.商户详情信息更新:
- 五、缓存穿透:
- 1.缓存空对象:
- 2.布隆过滤:
- 3.缓存空对象实现商户详情信息功能:
- 4.主动解决缓存穿透:
- 六、缓存雪崩:
- 1.解决方案
- 七、缓存击穿(只针对热点key):
- 1.互斥锁:
- 2.逻辑过期:
- 回顾的知识点:
- @Transactional注解:
- @PutMapping、@PostMapping注解:
- @RequestBody、@RequestParam、@PathVariable:
- 装箱与拆箱:
- 线程池ExecutorService:
一、缓存定义:
缓存就是数据交换的缓冲区Cache,是存储数据的临时区,读写性能比较高。
优势:
- 降低后端负载
- 提高服务器读写响应速度
劣势:
- 开发成本
- 一致性问题

二、使用Hash结构缓存商户详情信息:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;public Result queryById(long id){// 先查缓存Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(String.valueOf(id));// 缓存为空查mysqlif (entries.isEmpty()){Shop shop = getById(id);// mysql为空返回failif (shop == null){return Result.fail("商户信息不存在");}// 写入信息到缓存stringRedisTemplate.opsForHash().putAll(String.valueOf(id), BeanUtil.beanToMap(shop, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue)-> {if (fieldValue == null) {return null; // 显式处理 null 值}return fieldValue.toString();})));stringRedisTemplate.expire(String.valueOf(id), 30, TimeUnit.MINUTES);return Result.ok(shop);}return Result.ok(BeanUtil.fillBeanWithMap(entries,new Shop(),false));}
}
三、使用List结构缓存商户类型列表:
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {@Autowiredpublic StringRedisTemplate stringRedisTemplate;// SpringMVC中默认使用的JSON处理工具@Autowiredprivate ObjectMapper mapper;@Overridepublic Result queryTypeList() {// 先查redisList<String> shopTypeList = stringRedisTemplate.opsForList().range("shopTypeList", 0, -1);// redis为空if (shopTypeList.isEmpty()){// 查mysql数据库List<ShopType> typeList = query().orderByAsc("sort").list();// mysql数据库也为空if (typeList.isEmpty()){return Result.fail("无商户列表");}// 序列化将List<ShopType>转化为List<String>List<String> list = typeList.stream().map(str->{try {return mapper.writeValueAsString(str);//序列化} catch (JsonProcessingException e) {e.printStackTrace();}return null;//这里没用}).collect(Collectors.toList());// 写入List<String>到redisstringRedisTemplate.opsForList().leftPushAll("shopTypeList",list);// 返回List<ShopType>return Result.ok(typeList);}// 反序列化,输出要求是List<ShopType>List<ShopType> l = shopTypeList.stream().map(str->{try {return mapper.readValue(str, ShopType.class);//反序列化} catch (JsonProcessingException e) {e.printStackTrace();}return null;//这里没用}).collect(Collectors.toList());return Result.ok(l);}
}
四、缓存更新:
缓存更新:由调用者在更新数据库的同时更新缓存。
同时操作缓存和数据库有三个问题需要考虑:
- 删除缓存还是更新缓存?
- 如何保证缓存与数据库的操作同时成功或同时失败?
- 先操作缓存还是先操作数据库?
1.删除缓存还是更新缓存?
更新缓存:每次更新数据库都更新缓存。若更新次数多但是查询次数少,那么频繁的更新redis就是无效的操作,即更新缓存时无效写操作较多。
删除缓存:更新数据库时让缓存失效,查询时再重新更新缓存。不会出现无效写的问题,但是如果频繁交替执行更新和查询操作效率低。
一般使用删除缓存。
2.如何保证缓存与数据库的操作同时成功或失败?
若是单体系统,将缓存与数据库操作放在一个事务中。
若是分布式系统,利用TCC等分布式事务方案。(SpringCloud内容)
3.先操作缓存还是先操作数据库?
两种情况都可能出现线程安全问题:
先删除缓存再操作数据库,可能会出现读脏数据的情况。

初始缓存和数据库内容都是10。线程1删除缓存后,线程2获得调度查询数据库并写入脏数据到缓存,线程1更新数据库为20。这种情况的发生概率很高,因为删除和查询操作都很快,并且是针对缓存的,更快。但是更新操作就比较慢,而且是针对数据库的,很容易出现脏读的情况。
先操作数据库再删除缓存,出现脏读的几率很低,但不是完全不可能发生:

初始缓存和数据库内容都是10。当缓存失效时,线程1会读数据库得到10,此时线程2会更新数据库为20,然后山城1将得到的10写入缓存,才可能会导致脏读。但是更新数据库的操作是比较慢的,所以3小概率才能比4早执行,所以概率低。
4.缓存更新最佳方案:
- 读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,写入缓存
- 写操作:
- 先写数据库后删除缓存 (隔离性)
- 确保数据库与缓存操作要么全执行要么全不执行 (原子性)
5.商户详情信息更新:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Override@Transactional // 事务保障原子性(要么全执行要么全不执行)public Result update(Shop shop) {Long id = shop.getId();if(id == null){return Result.fail("无此商户");}// 更新数据库updateById(shop);// 删除缓存stringRedisTemplate.delete(String.valueOf(id));return Result.ok();}
}
五、缓存穿透:
缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会生效,这些请求都会到达数据库,增加数据库压力。
如果有人使用无数的线程并发的发起请求来获取数据库和缓存中都不存在的数据,那么所有请求都会到达数据库,从而导致数据库负载过大崩溃。

1.缓存空对象:
缓存空对象是指尽管某个请求获取的数据在数据库和缓存中都不存在,那么也会在缓存中缓存一个null值,下次如果还是相同的请求,那么就能在缓存中获取值,不用访问数据库,缓解数据库压力。

缺点:
- redis中缓存了过多垃圾信息导致额外的内存消耗(可以通过给null缓存设置一个较短的TTL来缓解这个问题)
- 可能会存在短期的数据不一致性。当用户访问时,数据库和缓存中都不存在对应的信息,所以在redis中缓存null,此时如果插入对应信息到数据库,那么用户后续访问时由于redis中有对应值,只能返回null,而对应数据是真实存在数据库中的,只有null的TTL过期后才能查数据库得到真实数据。(当插入数据到数据库时,主动将该数据更新到缓存可以解决这个问题,但是上面学到的更新数据都会先删缓存再更新,就就不会有这个不一致性问题了?)
2.布隆过滤:
布隆过滤器通过在redis之前引入一个过滤器,来判断当前请求的数据是否存在于redis或数据库,如果都不存在就直接拒绝访问redis和数据库。 防止负载过大。
布隆过滤是一种算法,是一个byte数组,对于数据库中的数据,给予某种哈希算法计算出哈希值,将哈希值转换成二进制位存储到byte数组中。当判断数据库中的数据是否存在时,通过判断byte数组中对应位置是0还是1以此判断请求的数据是否存在,空间占用小(bitMap数据结构)。
- 当布隆过滤器返回“不存在”时,那么请求的数据100%不存在。
- 当布隆过滤器返回“存在”时,那么请求的数据也不一定存在。(虽然缓解了缓存穿透,但由于不准确还是会有一定的缓存穿透问题)

3.缓存空对象实现商户详情信息功能:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;public Result queryById(long id){// 先查缓存Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(String.valueOf(id));// 缓存为空查mysql,这里的empty对于null和“”返回都是trueif (entries.isEmpty()){// 判断命中的是null还是空的HashMap,注意null!=空的HashMap,null是指没有这个对象,空的HashMap是指有对象但是内容为空if(entries == null){Result.fail("商户信息不存在");}Shop shop = getById(id);// mysql为空返回failif (shop == null){// 将null值写入redis,并设置较短的有效期stringRedisTemplate.opsForHash().putAll(String.valueOf(id), null);stringRedisTemplate.expire(String.valueOf(id), 3, TimeUnit.MINUTES);return Result.fail("商户信息不存在");}// 写入信息到缓存stringRedisTemplate.opsForHash().putAll(String.valueOf(id), BeanUtil.beanToMap(shop, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue)-> {if (fieldValue == null) {return null; // 显式处理 null 值}return fieldValue.toString();})));stringRedisTemplate.expire(String.valueOf(id), 30, TimeUnit.MINUTES);return Result.ok(shop);}return Result.ok(BeanUtil.fillBeanWithMap(entries,new Shop(),false));}
}
4.主动解决缓存穿透:
缓存空对象和布隆过滤都是出现缓存穿透后被动的进行处理。完全可以通过主动的方式来避免缓存穿透:
- 增强id的复杂度,在此基础上做好数据的基础格式校验,在格式校验阶段就能拦截,接触不到数据库。
- 加强用户权限校验。
- 做好热点参数的限流。
六、缓存雪崩:
缓存雪崩是指在同一时间大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
1.解决方案
- 给不同key的TTL添加随机值,防止因大量缓存key同时失效导致的缓存雪崩。
- 利用redis集群,确保一直有redis可用,防止因某一redis宕机引起的缓存雪崩。
- 当redis宕机时,牺牲部分服务,降级处理,不允许这些请求到达数据库,实现限流策略。
- 添加多级缓存。
七、缓存击穿(只针对热点key):
缓存击穿问题也叫热点key问题(注意是只针对热key,例如双11的部分热点商品,而非所有商品),就是一个被高并发访问且缓存重建业务较为复杂的key突然失效,无数的请求访问会在瞬间给数据库带来巨大的冲击。
高并发访问很好理解。缓存重建业务较为复杂时,分析如下,由于重建缓存耗时很长,当线程1重建缓存的过程中,其他多个线程此时要查询相同的信息,查询缓存还是会未命中,所以都会访问数据库:

解决方案:
互斥锁是牺牲效率提升数据一致性,逻辑过期是牺牲数据一致性保证效率:

具体要看企业需求权衡。

1.互斥锁:
- 互斥锁:重建缓存过程前上锁,重建完成释放锁,保证访问相同信息的所有线程中只能有一个请求重建缓存,其他请求由于获取锁失败暂时阻塞。(操作系统进程调度、进程同步)
Redis 的 SETNX(SET if Not eXists)能实现互斥锁,本质因为它是一个「单命令原子操作」
private boolean tryLock(String key){// 一般有效期设置业务执行时间的10倍左右Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "123456", 10, TimeUnit.SECONDS);// 注意Boolean!=boolean,会拆箱,如果setIfAbsent返回null,那么拆箱返回boolean时会空指针//return Boolean;return BooleanUtil.isTrue(flag);}private void unLock(String key){stringRedisTemplate.delete(key);}
这里思考为什么不能使用类的实例变量,因为if (isLocked)isLocked = false;是两步操作不是原子操作。

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;public Result queryById(long id){// 缓存穿透//Shop shop = queryByIdChuanTou(id);// 互斥锁解决缓存击穿(两部分感觉可以写一块)Shop shop1 = queryByIdJiChuan(id);if (shop1==null){return Result.fail("无此商户信息");}return Result.ok(shop1);}// 解决缓存击穿的逻辑public Shop queryByIdJiChuan(long id){// 先查缓存Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(String.valueOf(id));// 缓存为空查mysql,这里的empty对于null和“”返回都是trueif (entries.isEmpty()){// 判断命中的是null还是空的HashMap,注意null!=空的HashMap,null是指没有这个对象,空的HashMap是指有对象但是内容为空(缓存穿透)if(entries == null){return null;}Shop shop = null;try {// 获取互斥锁,注意这里不是直接id,id是商户信息而这里是锁,后面还会释放的boolean flag = tryLock("lock:shop:" + String.valueOf(id));// 获取失败,休眠并重新查缓存if (flag == false){Thread.sleep(50);return queryByIdJiChuan(id);}// 获取锁成功,查数据库shop = getById(id);// mysql为空返回fail(缓存穿透)if (shop == null){// 将null值写入redis,并设置较短的有效期stringRedisTemplate.opsForHash().putAll(String.valueOf(id), null);stringRedisTemplate.expire(String.valueOf(id), 3, TimeUnit.MINUTES);return null;}// 写入信息到缓存stringRedisTemplate.opsForHash().putAll(String.valueOf(id), BeanUtil.beanToMap(shop, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue)-> {if (fieldValue == null) {return null; // 显式处理 null 值}return fieldValue.toString();})));stringRedisTemplate.expire(String.valueOf(id), 30, TimeUnit.MINUTES);} catch (InterruptedException e) {e.printStackTrace();} finally {//释放锁unLock("lock:shop:" + String.valueOf(id));}return shop;}return BeanUtil.fillBeanWithMap(entries,new Shop(),false);}private boolean tryLock(String key){// 一般有效期设置业务执行时间的10倍左右Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "123456", 10, TimeUnit.SECONDS);// 注意Boolean!=boolean,会拆箱,如果setIfAbsent返回null,那么拆箱返回boolean时会空指针//return Boolean;return BooleanUtil.isTrue(flag);}private void unLock(String key){stringRedisTemplate.delete(key);}
2.逻辑过期:
- 逻辑过期: 不给缓存设置TTL,永不过期,而是给缓存添加一个expire的value,当expire时间减为0,那么线程访问时会访问数据库重建缓存,重建缓存过程中如果有其他线程访问该数据那么直接返回缓存中过期的数据。(这里不太理解,这种方法就是给热key数据设置永久的缓存,相比于简单的不设置过期时间还引入一系列额外的逻辑来增加数据库的访问,感觉多此一举。不过结合数据更新策略来思考应该是为了提高数据的一致性)

// 线程池private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);// 逻辑过期解决缓存击穿的逻辑public Shop queryByIdJiChuan2(long id){// 先查缓存Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(String.valueOf(id));// 缓存命中if (!entries.isEmpty()){RedisData data = BeanUtil.fillBeanWithMap(entries, new RedisData(), false);Shop shop1 = BeanUtil.copyProperties(data, Shop.class);// 判断是否过期,LocalDateTime.now()是当前时间if(data.getExpireTime().isAfter(LocalDateTime.now())){//未过期,直接返回旧值return shop1;}//已过期,缓存重建//获取互斥锁boolean flag = tryLock("hotkey:shop:" + id);if (flag == true){//获取锁成功,开始独立线程进行缓存重建,并直接返回过期值CACHE_REBUILD_EXECUTOR.submit(()->{Shop shop = getById(id);// 添加逻辑过期时间,这里采用封装的方式RedisData data1 = new RedisData();data1.setData(shop);data1.setExpireTime(LocalDateTime.now().plusSeconds(60));// 逻辑过期时间为当前时间+60sstringRedisTemplate.opsForValue().set(String.valueOf(id), JSONUtil.toJsonStr(data1));// 释放锁unLock("hotkey:shop:" + id);});}//获取锁失败,直接返回过期值return shop1;}// 未命中表示这个查询信息根本不是热key,return null;}
// 封装类
@Data
public class RedisData {// LocalDateTime不可变的日期时间对象,不包含时区信息private LocalDateTime expireTime;//保存Shop类型的对象private Object data;
}
回顾的知识点:
@Transactional注解:
Spring事务管理的核心注解,主要是保证原子性操作的(要么全执行要么全不执行),不涉及隔离性。
Spring 事务默认的回滚规则:
- 只有未捕获的RuntimeException(运行时异常)或Error才会触发回滚,而普通的Exception(检查异常)不会触发回滚。
- Spring 只有在方法抛出异常时,才会触发回滚。如果你在catch里吞掉了异常,那事务也就不会回滚了,如果使用catch就一定要throw。
- Spring事务是通过代理机制实现的,而JDK动态代理只能代理public方法,所以其他访问级别的方法都不行。
- 因为Spring事务是通过代理机制实现的,所以对象必须要交由Spring来动态实例化,手动实例化的对象,其方法加了@Transactional也不会生效。
@Transactional 底层逻辑:
- @Transactional 本质是「AOP环绕通知」对目标方法的增强,Spring启动时,扫描并解析@Transactional注解,对标注该注解的Bean,通过动态代理生成代理对象(默认:接口用JDK动态代理,类用CGLIB代理),目标对象方法的执行会被代理对象拦截。
- 代理对象拦截目标方法后,先执行事务准备工作:关闭JDBC连接的自动提交(autoCommit=false)、设置连接的隔离级别、通过ThreadLocal将当前Connection绑定到线程确保同一事务内的所有SQL操作使用同一个连接。
- 代理对象调用目标方法,执行业务逻辑中的 SQL 操作:无异常,通过Connection.commit()提交事务;有异常通过Connection.rollback()回滚事务,撤销本次连接中所有SQL 操作,数据库恢复到事务执行前的状态。
@PutMapping、@PostMapping注解:
- @GetMapping: 处理get请求,传统的RequestMapping来编写应该是@RequestMapping(value = “/get/{id}”, method=RequestMethod.GET)。新方法可以简写为:@GetMapping(“/get/{id}”)
- @PostMapping: 处理post请求,传统的RequestMapping来编写应该是@RequestMapping(value = “/get”,method = RequestMethod.POST)。新方法可以简写为:@PostMapping(“/get”)
- @PutMapping: 和PostMapping作用等同,都是用来向服务器提交信息。如果是添加信息,倾向于用@PostMapping,如果是更新信息,倾向于用@PutMapping。当我们发送两个相同的请求:
- 如果执行添加操作, 后面的添加请求不会覆盖前面添加的请求, 所以使用@Postmapping
- 如果执行修改操作, 后面的修改请求会把前面的修改请求给覆盖掉, 所以使用@PutMapping
@RequestBody、@RequestParam、@PathVariable:
@RequestParam、@RequestBody和@PathVariable是Spring MVC中常用的参数绑定注解。
- @RequestParam接收的参数来自URL。一般接受简单的字符串、数组类型,用于将HTTP请求中的参数绑定到方法的参数上,主要用于处理GET请求的参数或POST请求中的表单参数,常用于查询操作。
// 前端请求:GET /user?name=张三&age=20 或 POST /user(表单提交 name=张三&age=20)
@GetMapping("/user") // 换成 @PostMapping 也能接收表单参数
public String getUser(@RequestParam String name, // 必传参数(默认)@RequestParam Integer age,@RequestParam(defaultValue = "男") String gender, // 可选参数,默认值“男”@RequestParam(required = false) String address) { // 可选参数,允许为 nullSystem.out.println("姓名:" + name + ",年龄:" + age + ",性别:" + gender);return "success";
}
- @PathVariable与@RequestParam类似,接收的参数来自URL,只是URL的写法不同:
// 前端请求:GET /user/1001 或 POST /user/1001
@GetMapping("/user/{userId}") // 路径中的 {userId} 是占位符
public String getUserById(@PathVariable Long userId) { // 接收路径中的 userIdSystem.out.println("用户ID:" + userId);return "success";
}// 前后端参数名不一致时,指定 name
@PostMapping("/order/{orderNo}")
public String getOrder(@PathVariable(name = "orderNo") String orderNumber) {System.out.println("订单号:" + orderNumber);return "success";
}
- @RequestBody接收的参数是来自requestBody中,即请求体。一般接收请求体中复杂的JSON类型,用于接收整个请求体,并将其转换为方法参数所需的对象,常用于插入或更新操作。
// 前端请求:POST /user ,请求体为 JSON:{"name":"张三","age":20,"gender":"男"}
@PostMapping("/user")
public String addUser(@RequestBody User user) { // 自动将 JSON 转为 User 对象(需 jackson 依赖)System.out.println("新增用户:" + user.getName() + ",年龄:" + user.getAge());return "success";
}// 实体类(需提供 getter/setter 或用 @Data 注解)
@Data // Lombok 注解,简化 getter/setter
public class User {private String name;private Integer age;private String gender;
}
装箱与拆箱:
JDK 1.5 开始,Java 引入了自动装箱与拆箱的功能,编译器会自动完成类型之间的转换,底层还是下述代码,只是简化了操作。
public class ManualBoxing {public static void main(String[] args) {// 手动装箱:将 int 转换为 Integerint num = 10;Integer boxedNum = Integer.valueOf(num); // 手动装箱System.out.println("装箱后的值:" + boxedNum);// 手动拆箱:将 Integer 转换为 intint unboxedNum = boxedNum.intValue(); // 手动拆箱System.out.println("拆箱后的值:" + unboxedNum);}
}
- 装箱注意事项:
装箱操作涉及到将一个 基本数据类型 转换为 包装类对象,这需要为包装类对象在堆内存中分配空间,对于频繁使用装箱操作的代码来说,会导致内存压力增大,并且可能会导致垃圾回收的负担增加。
- 拆箱注意事项:
在拆箱时,如果包装类对象为 null,会抛出 NullPointerException:
Integer num = null;
int value = num; // 会抛出 NullPointerException
避免使用 == 比较包装类对象,使用 equals() 方法进行值的比较,以避免不必要的错误。
线程池ExecutorService:
参考资料:作者 simpleDi
许多服务器应用程序都面向处理来自某些远程来源的大量短小的任务,每当一个请求到达就创建一个新线程,然后在新线程中为请求服务,但是频繁创建新线程、销毁新线程既花费较多的时间,影响相应速度,又消耗大量的系统资源,且有时服务器无法处理过多请求导致崩溃。一种情形:假设一个服务器完成一项任务所需时间为:T1创建线程时间,T2在线程中执行任务的时间,T3销毁线程时间。 如果:T1+T3远大于T2,则可以采用线程池,以提高服务器性能。ExecutorService是一个线程池,请求到达时,线程已经存在,响应延迟低,多个任务复用线程,避免了线程的重复创建和销毁,并且可以规定线程数目,请求数目超过阈值时强制其等待直到有空闲线程。
