在.vscode文件中的launch.json文件中修改相关配置加入"vmArgs": "-Dserver.port=8081 -Denv=replica"如：{ "type": "java", "name": "Service-8081 (Replica)", "request": "launch", "cwd": "${workspaceFolder}", "mainClass": "com.hmdp.HmDianPingApplication", "projectName": "hm-dianping", "envFile": "${workspaceFolder}/.env", "vmArgs": "-Dserver.port=8081 -Denv=replica", "args": "" }其中 name决定名字，vmArgs中port决定端口然后打开左侧边栏的调试，选择相应的启动即可
介绍StringRedisTemplate 是 Spring Data Redis 提供的专门操作 Redis 字符串数据的模板类，其键（key）和值（value）默认采用字符串序列化（StringRedisSerializer），无需额外配置即可高效处理文本数据。适用于缓存、会话管理等场景StringRedisTemplate 和 RedisTemplate 是 Spring Data Redis 中两个核心操作类，二者为继承关系（StringRedisTemplate 继承 RedisTemplate），但在序列化机制、数据存储格式及适用场景上有显著区别。以下从多个维度详细说明其关系与差异：特性StringRedisTemplateRedisTemplate继承关系子类（继承 RedisTemplate）父类（泛型类，支持任意类型）键序列化器StringRedisSerializer默认 JdkSerializationRedisSerializer值序列化器StringRedisSerializer默认 JdkSerializationRedisSerializer数据存储格式字符串（可读性强，如 "user:1000"）JDK 二进制序列化（不可读，如 \xac\xed\x00\x05t...）跨语言兼容性高（通用字符串格式）低（依赖 Java 反序列化）可视化友好性✅ Redis CLI 可直接查看数据❌ 二进制数据不可读核心特性与优势序列化简化默认使用 StringRedisSerializer，键值均为字符串，避免 JDK 序列化（如 RedisTemplate 的字节数组格式），数据在 Redis 中可读性更强。节省内存：不存储类信息（Class Metadata），降低内存占用。开箱即用无需自定义序列化，注入即可操作：@Autowired private StringRedisTemplate stringRedisTemplate;多数据类型支持通过不同接口操作 Redis 的五种核心数据结构：sdaopsForValue(); // 操作字符串 opsForHash(); // 操作哈希表 opsForList(); // 操作列表 opsForSet(); // 操作集合 opsForZSet(); // 操作有序集合使用场景对比数据类型优势适用场景典型 API字符串（String）简单高效，支持原子增减缓存、计数器、令牌管理set(), get(), increment()哈希（Hash）结构化存储对象属性用户信息、商品详情put(), entries(), get()列表（List）有序元素，支持双向操作消息队列、操作日志leftPush(), range(), leftPop()集合（Set）元素唯一性，快速查找标签系统、好友关系add(), members(), isMember()常用 API 分类详解字符串（String）操作方法作用示例set(key, value)存储键值对stringRedisTemplate.opsForValue().set("user:1", "Alice")set(key, value, timeout, timeUnit)存值并设置过期时间opsForValue().set("token", "xyz", 30, TimeUnit.MINUTES)get(key)根据键获取值String name = opsForValue().get("user:1")increment(key, delta)值递增（整数）opsForValue().increment("counter", 1)decrement(key, delta)值递减（整数）opsForValue().decrement("stock", 10)setIfAbsent(key, value)仅当键不存在时存储Boolean success = opsForValue().setIfAbsent("lock", "1")哈希（Hash）操作方法作用示例put(key, hashKey, value)向哈希表添加字段opsForHash().put("user:100", "age", "25")get(key, hashKey)获取哈希表字段值String age = opsForHash().get("user:100", "age")entries(key)获取整个哈希表Map user = opsForHash().entries("user:100")delete(key, hashKeys)删除哈希表字段opsForHash().delete("user:100", "address")列表（List）操作方法作用示例leftPush(key, value)从左侧插入元素opsForList().leftPush("queue", "task1")rightPush(key, value)从右侧插入元素opsForList().rightPush("log", "error1")leftPop(key)从左侧弹出元素String task = opsForList().leftPop("queue")range(key, start, end)获取列表区间元素List logs = opsForList().range("log", 0, 10)通用操作方法作用示例delete(key)删除键stringRedisTemplate.delete("user:1")hasKey(key)检查键是否存在Boolean exists = stringRedisTemplate.hasKey("token")getExpire(key)获取键的剩余时间（秒）Long ttl = stringRedisTemplate.getExpire("token")expire(key, timeout, timeUnit)设置键过期时间stringRedisTemplate.expire("session", 10, TimeUnit.MINUTES)配置与序列化说明依赖与配置<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>spring: redis: host: localhost port: 6379 password: 123456 timeout: 3000序列化对比StringRedisTemplate：键值强制转为字符串（StringRedisSerializer），数据在 Redis 中以可读文本存储（如 "user:1"）。RedisTemplate：默认 JDK 序列化（JdkSerializationRedisSerializer），数据为字节数组（如 \xac\xed\x00\x05t\x00\x05user），需手动配置 JSON 序列化。
在当今的Web开发中，安全认证和授权变得尤为重要。 JWT（JSON Web Tokens）是一种开放标准（RFC 7519），它定义了一种简洁、紧凑且自包含的方式，用于在各方之间安全地传输信息。但这种传输并不是保密的传输,只是验证真伪的传输,信息是可以被看见的,只是做不了假jwt就是一个简单的字符串。可以在请求参数或者是请求头当中直接传递。紧凑：指的是它能够以较小的体积包含所有的信息。JWT使用Base64Url编码算法JWT支持多种加密算法，如HS256、RS256等，这些算法能够在保证安全性的同时，保持令牌的大小不会过大。自包含：指的是jwt令牌，看似是一个随机的字符串，但是我们是可以根据自身的需求在jwt令牌中存储自定义的数据内容。JWT包含了所有必要的信息来验证其有效性，不需要额外的查询或数据库操作。简单来说就是将原始的json数据格式进行了安全的封装，这样就可以直接基于jwt在通信双方安全的进行信息传输JWT的组成JWT由三部分组成：头部（Header）、载荷（Payload）和签名（Signature）用.分隔。这三部分以.分隔，形如xxxxx.yyyyy.zzzzz头部包含了令牌的类型和所使用的加密算法编码后：eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9解码后：{ "alg": "HS256",//加密的算法 "typ": "JWT"//令牌的类型 }头部告诉我们签名算法（HS256）和令牌类型（JWT）载荷存放实际的数据，可以是任何形式的JSON对象编码后：eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ 解码后：{ "name": "John Doe", "admin": true, "roles": ["user", "admin"], "exp": 1687016741 }载荷包含了声明信息，如用户姓名、是否为管理员、用户角色和过期时间。签名用于验证令牌的发送者和内容是否被篡改。签名由头部、载荷、秘钥和指定的算法生成签名是对头部和载荷的加密，确保令牌的完整性和可靠性在生成JWT令牌时，会对JSON格式的数据进行一次编码：进行base64编码JWT使用导入JWT依赖<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.10.3</version> </dependency>生成tokenMap<String, Object> claims = new HashMap<>(); claims.put(JwtClaimsConstant.EMP_ID, employee.getId()); String token = JwtUtil.createJWT( jwtProperties.getAdminSecretKey(), jwtProperties.getAdminTtl(), claims);配置拦截器在WebMvcConfiguration中重写拦截器接口addInterceptors，每次访问/admin目录下的文件都会经过此拦截器进行jwt验证。@Configuration @Slf4j public class WebMvcConfiguration extends WebMvcConfigurationSupport { ​ @Autowired private JwtTokenAdminInterceptor jwtTokenAdminInterceptor; ​ /** * 注册自定义拦截器 * * @param registry */ protected void addInterceptors(InterceptorRegistry registry) { log.info("开始注册自定义拦截器..."); registry.addInterceptor(jwtTokenAdminInterceptor) .addPathPatterns("/admin/**") .excludePathPatterns("/admin/employee/login"); }验证解析token创建拦截器的类JwtInterceptor，用于配置WebMvcConfigurer，将自己的自定义拦截器装配到容器中；jwt拦截器文件JwtInterceptor，重写preHandle方法，校验通过拦截器放行，不通过返回错误的状态码。@Component @Slf4j public class JwtTokenAdminInterceptor implements HandlerInterceptor { ​ @Autowired private JwtProperties jwtProperties; ​ /** * 校验jwt * @param request * @param response * @param handler * @return * @throws Exception下 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //判断当前拦截到的是Controller的方法还是其他资源 if (!(handler instanceof HandlerMethod)) { //当前拦截到的不是动态方法，直接放行 return true; } ​ //1、从请求头中获取令牌 String token = request.getHeader(jwtProperties.getAdminTokenName()); ​ //2、校验令牌 try { log.info("jwt校验:{}", token); Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token); Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString()); log.info("当前员工id：", empId); // 通过ThreadLocal存储当前员工id BaseContext.setCurrentId(empId); //3、通过，放行 return true; } catch (Exception ex) { //4、不通过，响应401状态码 response.setStatus(401); return false; } } }这里使用了一个名为Jwts的库，它提供了用于解析JWT的工具。.parser()方法创建了一个用于解析JWT的解析器实例JWT解析器需要知道签名秘钥来验证JWT的签名部分。.setSigningKey()方法用于设置这个秘钥。这里的秘钥是secretKey参数，它首先被转换为UTF-8字节数组，这是因为JWT的签名部分是基于字节进行计算的.parseClaimsJws(token)这个方法用于解析JWT的字符串形式。token参数是要解析的JWT字符串解析过程中，会验证JWT的签名是否与通过秘钥计算出的签名一致。签名验证是一个关键步骤，确保JWT没有被篡改如果签名验证通过，解析器会返回一个Jws<claims>对象，其中包含JWT的三个部分：头部（header）、有效载荷（claims）和签名（signature）.getBody()方法用于提取JWT的有效载荷部分Jwt工具类public class JwtUtil { /** * 生成jwt * 使用Hs256算法, 私匙使用固定秘钥 * * @param secretKey jwt秘钥 * @param ttlMillis jwt过期时间(毫秒) * @param claims 设置的信息 * @return */ public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) { // 指定签名的时候使用的签名算法，也就是header那部分 SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; ​ // 生成JWT的时间 long expMillis = System.currentTimeMillis() + ttlMillis;//当前时间戳加上递给方法的JWT过期时间 Date exp = new Date(expMillis); ​ // 设置jwt的body JwtBuilder builder = Jwts.builder() // 如果有私有声明，一定要先设置这个自己创建的私有的声明，这个是给builder的claim赋值，一旦写在标准的声明赋值之后，就是覆盖了那些标准的声明的 .setClaims(claims) // 设置签名使用的签名算法和签名使用的秘钥 .signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8)) // 设置过期时间 .setExpiration(exp); ​ return builder.compact(); } /** * Token解密 * * @param secretKey jwt秘钥 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个 * @param token 加密后的token * @return */ public static Claims parseJWT(String secretKey, String token) { // 得到DefaultJwtParser Claims claims = Jwts.parser() // 设置签名的秘钥 .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)) // 设置需要解析的jwt .parseClaimsJws(token).getBody(); return claims; } }Jwt相关配置package com.sky.properties; ​ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; ​ @Component @ConfigurationProperties(prefix = "sky.jwt") @Data public class JwtProperties { ​ /** * 管理端员工生成jwt令牌相关配置 */ private String adminSecretKey; private long adminTtl; private String adminTokenName; ​ /** * 用户端微信用户生成jwt令牌相关配置 */ private String userSecretKey; private long userTtl; private String userTokenName; }application.yml配置sky: jwt: # 设置jwt签名加密时使用的秘钥 admin-secret-key: itcast # 设置jwt过期时间 admin-ttl: 7200000 # 设置前端传递过来的令牌名称 admin-token-name: token常见的JWT异常SignatureVerificationException: 无效签名TokenExpiredException: token过期AlgorithmMismatchException: token算法不一致InvalidClaimException: 失效payload异常参考文章：https://blog.csdn.net/m0\_57752520/article/details/125785908
项目介绍本项目（苍穹外卖）是专门为餐饮企业（餐厅、饭店）定制的一款软件产品，包括 系统管理后台 和 小程序端应用 两部分。其中系统管理后台主要提供给餐饮企业内部员工使用，可以对餐厅的分类、菜品、套餐、订单、员工等进行管理维护，对餐厅的各类数据进行统计，同时也可进行来单语音播报功能。小程序端主要提供给消费者使用，可以在线浏览菜品、添加购物车、下单、支付、催单等。项目架构功能架构图管理端功能员工登录/退出 , 员工信息管理 , 分类管理 , 菜品管理 , 套餐管理 , 菜品口味管理 , 订单管理 ，数据统计，来单提醒[collapse status="false" title="功能列表"]模块描述登录/退出内部员工必须登录后,才可以访问系统管理后台员工管理管理员可以在系统后台对员工信息进行管理，包含查询、新增、编辑、禁用等功能分类管理主要对当前餐厅经营的 菜品分类 或 套餐分类 进行管理维护， 包含查询、新增、修改、删除等功能菜品管理主要维护各个分类下的菜品信息，包含查询、新增、修改、删除、启售、停售等功能套餐管理主要维护当前餐厅中的套餐信息，包含查询、新增、修改、删除、启售、停售等功能订单管理主要维护用户在移动端下的订单信息，包含查询、取消、派送、完成，以及订单报表下载等功能数据统计主要完成对餐厅的各类数据统计，如营业额、用户数量、订单等[/collapse]用户端功能微信登录 , 收件人地址管理 , 用户历史订单查询 , 菜品规格查询 , 购物车功能 , 下单 , 支付、分类及菜品浏览[collapse status="false" title="功能列表"]模块描述登录/退出用户需要通过微信授权后登录使用小程序进行点餐点餐-菜单在点餐界面需要展示出菜品分类/套餐分类, 并根据当前选择的分类加载其中的菜品信息, 供用户查询选择点餐-购物车用户选中的菜品就会加入用户的购物车, 主要包含 查询购物车、加入购物车、删除购物车、清空购物车等功能订单支付用户选完菜品/套餐后, 可以对购物车菜品进行结算支付, 这时就需要进行订单的支付个人信息在个人中心页面中会展示当前用户的基本信息, 用户可以管理收货地址, 也可以查询历史订单数据[/collapse]技术栈关于本项目的技术选型, 我们将会从 用户层、网关层、应用层、数据层 这几个方面进行介绍，主要用于展示项目中使用到的技术框架和中间件等。应用层SpringBoot： 快速构建Spring项目, 采用 "约定优于配置" 的思想, 简化Spring项目的配置开发SpringMVC：SpringMVC是spring框架的一个模块，springmvc和spring无需通过中间整合层进行整合，可以无缝集成Spring Task: 由Spring提供的定时任务框架httpclient: 主要实现了对http请求的发送Spring Cache: 由Spring提供的数据缓存框JWT: 用于对应用程序上的用户进行身份验证的标记阿里云OSS: 对象存储服务，在项目中主要存储文件，如图片等Swagger： 可以自动的帮助开发人员生成接口文档，并对接口进行测试POI: 封装了对Excel表格的常用操作WebSocket: 一种通信网络协议，使客户端和服务器之间的数据交换更加简单，用于项目的来单、催单功能实现数据层MySQL： 关系型数据库, 本项目的核心业务数据都会采用MySQL进行存储Redis： 基于key-value格式存储的内存数据库, 访问速度快, 经常使用它做缓存Mybatis： 本项目持久层将会使用Mybatis开发pagehelper: 分页插件spring data redis: 简化java代码操作Redis的API开发环境后端环境后端工程基于 maven 进行项目构建，并且进行分模块开发。整体结构对工程的每个模块作用说明：[collapse status="false" title="作用说明"]序号名称说明1sky-take-outmaven父工程，统一管理依赖版本，聚合其他子模块2sky-common子模块，存放公共类，例如：工具类、常量类、异常类等3sky-pojo子模块，存放实体类、VO、DTO等4sky-server子模块，后端服务，存放配置文件、Controller、Service、Mapper等[/collapse]sky-common模块中存放的是一些公共类，可以供其他模块使用分析sky-common模块的每个包的作用：[collapse status="false" title="作用说明"]名称说明constant存放相关常量类context存放上下文类enumeration项目的枚举类存储exception存放自定义异常类json处理json转换的类properties存放SpringBoot相关的配置属性类result返回结果类的封装utils常用工具类[/collapse]sky-pojo:模块中存放的是一些 entity、DTO、VO分析sky-pojo模块的每个包的作用：[collapse status="false" title="作用说明"]名称说明Entity实体，通常和数据库中的表对应DTO数据传输对象，通常用于程序中各层之间传递数据VO视图对象，为前端展示数据提供的对象POJO普通Java对象，只有属性和对应的getter和setter[/collapse]sky-server模块中存放的是 配置文件、配置类、拦截器、controller、service、mapper、启动类等分析sky-server模块的每个包的作用：[collapse status="false" title="作用说明"]名称说明config存放配置类controller存放controller类interceptor存放拦截器类mapper存放mapper接口service存放service类SkyApplication启动类[/collapse]数据库环境整体结构[collapse status="false" title="整体结构"]序号数据表名中文名称1employee员工表2category分类表3dish菜品表4dish_flavor菜品口味表5setmeal套餐表6setmeal_dish套餐菜品关系表7user用户表8address_book地址表9shopping_cart购物车表10orders订单表11order_detail订单明细表[/collapse]employee[collapse status="false" title="具体结构"]字段名数据类型说明备注idbigint主键自增namevarchar(32)姓名 usernamevarchar(32)用户名唯一passwordvarchar(64)密码 phonevarchar(11)手机号 sexvarchar(2)性别 id_numbervarchar(18)身份证号 statusint账号状态1正常 0锁定create_timedatetime创建时间 update_timedatetime最后修改时间 create_userbigint创建人id update_userbigint最后修改人id [/collapse]categorycategory表为分类表，用于存储商品的分类信息。具体表结构如下：[collapse status="false" title="具体结构"]字段名数据类型说明备注idbigint主键自增namevarchar(32)分类名称唯一typeint分类类型1菜品分类 2套餐分类sortint排序字段用于分类数据的排序statusint状态1启用 0禁用create_timedatetime创建时间 update_timedatetime最后修改时间 create_userbigint创建人id update_userbigint最后修改人id [/collapse]dishdish表为菜品表，用于存储菜品的信息。具体表结构如下：[collapse status="false" title="具体结构"]字段名数据类型说明备注idbigint主键自增namevarchar(32)菜品名称唯一category_idbigint分类id逻辑外键pricedecimal(10,2)菜品价格 imagevarchar(255)图片路径 descriptionvarchar(255)菜品描述 statusint售卖状态1起售 0停售create_timedatetime创建时间 update_timedatetime最后修改时间 create_userbigint创建人id update_userbigint最后修改人id [/collapse]dish_flavordish_flavor表为菜品口味表，用于存储菜品的口味信息。具体表结构如下：[collapse status="false" title="具体结构"]字段名数据类型说明备注idbigint主键自增dish_idbigint菜品id逻辑外键namevarchar(32)口味名称 valuevarchar(255)口味值 [/collapse]setmealsetmeal表为套餐表，用于存储套餐的信息。具体表结构如下：[collapse status="false" title="具体结构"]字段名数据类型说明备注idbigint主键自增namevarchar(32)套餐名称唯一category_idbigint分类id逻辑外键pricedecimal(10,2)套餐价格 imagevarchar(255)图片路径 descriptionvarchar(255)套餐描述 statusint售卖状态1起售 0停售create_timedatetime创建时间 update_timedatetime最后修改时间 create_userbigint创建人id update_userbigint最后修改人id [/collapse]setmeal_dishsetmeal_dish表为套餐菜品关系表，用于存储套餐和菜品的关联关系。具体表结构如下：[collapse status="false" title="具体结构"]字段名数据类型说明备注idbigint主键自增setmeal_idbigint套餐id逻辑外键dish_idbigint菜品id逻辑外键namevarchar(32)菜品名称冗余字段pricedecimal(10,2)菜品单价冗余字段copiesint菜品份数 [/collapse]useruser表为用户表，用于存储C端用户的信息。具体表结构如下：[collapse status="false" title="具体结构"]字段名数据类型说明备注idbigint主键自增openidvarchar(45)微信用户的唯一标识 namevarchar(32)用户姓名 phonevarchar(11)手机号 sexvarchar(2)性别 id_numbervarchar(18)身份证号 avatarvarchar(500)微信用户头像路径 create_timedatetime注册时间 [/collapse]address_bookaddress_book表为地址表，用于存储C端用户的收货地址信息。具体表结构如下：[collapse status="false" title="具体结构"]字段名数据类型说明备注idbigint主键自增user_idbigint用户id逻辑外键consigneevarchar(50)收货人 sexvarchar(2)性别 phonevarchar(11)手机号 province_codevarchar(12)省份编码 province_namevarchar(32)省份名称 city_codevarchar(12)城市编码 city_namevarchar(32)城市名称 district_codevarchar(12)区县编码 district_namevarchar(32)区县名称 detailvarchar(200)详细地址信息具体到门牌号labelvarchar(100)标签公司、家、学校is_defaulttinyint(1)是否默认地址1是 0否[/collapse]shopping_cartshopping_cart表为购物车表，用于存储C端用户的购物车信息。具体表结构如下：[collapse status="false" title="具体结构"]字段名数据类型说明备注idbigint主键自增namevarchar(32)商品名称 imagevarchar(255)商品图片路径 user_idbigint用户id逻辑外键dish_idbigint菜品id逻辑外键setmeal_idbigint套餐id逻辑外键dish_flavorvarchar(50)菜品口味 numberint商品数量 amountdecimal(10,2)商品单价 create_timedatetime创建时间 [/collapse]ordersorders表为订单表，用于存储C端用户的订单数据。具体表结构如下：[collapse status="false" title="具体结构"]字段名数据类型说明备注idbigint主键自增numbervarchar(50)订单号 statusint订单状态1待付款 2待接单 3已接单 4派送中 5已完成 6已取消user_idbigint用户id逻辑外键address_book_idbigint地址id逻辑外键order_timedatetime下单时间 checkout_timedatetime付款时间 pay_methodint支付方式1微信支付 2支付宝支付pay_statustinyint支付状态0未支付 1已支付 2退款amountdecimal(10,2)订单金额 remarkvarchar(100)备注信息 phonevarchar(11)手机号 addressvarchar(255)详细地址信息 user_namevarchar(32)用户姓名 consigneevarchar(32)收货人 cancel_reasonvarchar(255)订单取消原因 rejection_reasonvarchar(255)拒单原因 cancel_timedatetime订单取消时间 estimated_delivery_timedatetime预计送达时间 delivery_statustinyint配送状态1立即送出 0选择具体时间delivery_timedatetime送达时间 pack_amountint打包费 tableware_numberint餐具数量 tableware_statustinyint餐具数量状态1按餐量提供 0选择具体数量[/collapse]order_detailorder_detail表为订单明细表，用于存储C端用户的订单明细数据。具体表结构如下：[collapse status="false" title="具体结构"]字段名数据类型说明备注idbigint主键自增namevarchar(32)商品名称 imagevarchar(255)商品图片路径 order_idbigint订单id逻辑外键dish_idbigint菜品id逻辑外键setmeal_idbigint套餐id逻辑外键dish_flavorvarchar(50)菜品口味 numberint商品数量 amountdecimal(10,2)商品单价 [/collapse]业务实现新增员工新增员工，就是将我们新增页面录入的员工数据插入到employee表employee表为员工表，用于存储商家内部的员工信息。具体表结构如下：设计DTO类前端传递参数列表：其中，Employee类中的数据远多于提交的数据当前端提交的数据和实体类中对应的属性差别比较大时，建议使用DTO来封装数据所以自定义DTO类package com.sky.dto; import lombok.Data; import java.io.Serializable; @Data public class EmployeeDTO implements Serializable { private Long id; private String username; private String name; private String phone; private String sex; private String idNumber; }其中，Serializable 是一个标记接口，它表示一个类的对象可以被序列化，EmployeeDTO 类实现了 Serializable 接口，这意味着 EmployeeDTO 类的对象可以被序列化，例如保存到文件中或者通过网络传输Controller层进入到sky-server模块中，在com.sky.controller.admin包下，在EmployeeController中创建新增员工方法，接收前端提交的参数。@PostMapping public Result save(@RequestBody EmployeeDTO employeeDTO){ log.info("新增员工：{}",employeeDTO); employeeService.save(employeeDTO);//调用Server层中的save函数，传递employeeDTO return Result.success(); }其中，Result类定义了后端统一返回结果格式。进入sky-common模块，在com.sky.result包下定义了Result.javapackage com.sky.result; import lombok.Data; import java.io.Serializable; @Data public class Result<T> implements Serializable { private Integer code; //编码：1成功，0和其它数字为失败 private String msg; //错误信息 private T data; //数据 public static <T> Result<T> success() { Result<T> result = new Result<T>(); result.code = 1; return result; } public static <T> Result<T> success(T object) { Result<T> result = new Result<T>(); result.data = object; result.code = 1; return result; } public static <T> Result<T> error(String msg) { Result result = new Result(); result.msg = msg; result.code = 0; return result; } }<t>表示泛型，返回的类型由调用者提供的数据决定，如果你调用 Result.success("Hello, World!")，这里的 T 是 String，那么返回的 Result 对象的 data 字段将是一个 String 类型的数据。如果你调用 Result.success(123)，这里的 T 是 Integer，那么返回的 Result 对象的 data 字段将是一个 Integer 类型的数据Service层接口在EmployeeService接口中声明新增员工方法进入到sky-server模块中,com.sky.server.EmployeeServicevoid save(EmployeeDTO employeeDTO);Service层实现类在EmployeeServiceImpl中实现新增员工方法com.sky.server.impl.EmployeeServiceImpl中创建方法public void save(EmployeeDTO employeeDTO) { Employee employee = new Employee(); //对象属性拷贝,将employeeDTO的值复制到employee中 BeanUtils.copyProperties(employeeDTO, employee); //设置账号的状态，默认正常状态 1表示正常 0表示锁定 employee.setStatus(StatusConstant.ENABLE); //设置密码，默认密码123456 employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes())); //设置当前记录的创建时间和修改时间 employee.setCreateTime(LocalDateTime.now()); employee.setUpdateTime(LocalDateTime.now()); //设置当前记录创建人id和修改人id employee.setCreateUser(10L);//目前写个假数据，后期修改 employee.setUpdateUser(10L); employeeMapper.insert(employee);//后续步骤定义 }在sky-common模块com.sky.constants包下已定义StatusConstant.java，引用这个包来表示是否开启用户package com.sky.constant; public class StatusConstant { //启用 public static final Integer ENABLE = 1; //禁用 public static final Integer DISABLE = 0; }Mapper层在EmployeeMapper中声明insert方法com.sky.EmployeeMapper中添加方法@Insert("insert into employee (name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user,status) " + "values " + "(#{name},#{username},#{password},#{phone},#{sex},#{idNumber},#{createTime},#{updateTime},#{createUser},#{updateUser},#{status})") void insert(Employee employee);在application.yml中已开启驼峰命名，故id_number和idNumber可对应。mybatis: configuration: #开启驼峰命名 map-underscore-to-camel-case: true处理抛出异常录入的用户名已存，抛出的异常后没有处理后台报错信息：查看employee表结构：发现，username已经添加了唯一约束，不能重复。解决：通过全局异常处理器来处理。进入到sky-server模块，com.sky.hander包下，GlobalExceptionHandler.java添加方法@ExceptionHandler public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){= String message = ex.getMessage(); //获取到的内容即Duplicate entry 'zhangsan' for key 'employee.idx_username' if(message.contains("Duplicate entry")){ String[] split = message.split(" "); String username = split[2]; String msg = username + MessageConstant.ALREADY_EXISTS; return Result.error(msg); }else{ return Result.error(MessageConstant.UNKNOWN_ERROR); } }进入到sky-common模块，在MessageConstant.java添加public static final String ALREADY_EXISTS = "已存在";再次，接口测试：生成JWT在Service层实现类中我们设置的Id的方式为employee.setCreateUser(10L); employee.setUpdateUser(10L);此值为固定值，但我们需要获取当前修改人的id，并且将其设置进去，需要一种动态的方式获取员工的Id利用JWT令牌可完成需求员工登录成功后会生成JWT令牌并响应给前端，后续请求中，前端会携带JWT令牌，通过JWT令牌可以解析出当前登录员工idtoken生成逻辑代码：Map<String, Object> claims = new HashMap<>(); claims.put(JwtClaimsConstant.EMP_ID, employee.getId()); String token = JwtUtil.createJWT( jwtProperties.getAdminSecretKey(), jwtProperties.getAdminTtl(), claims);配置拦截器package com.sky.interceptor; /** * jwt令牌校验的拦截器 */ @Component @Slf4j public class JwtTokenAdminInterceptor implements HandlerInterceptor { @Autowired private JwtProperties jwtProperties; /** * 校验jwt * * @param request * @param response * @param handler * @return * @throws Exception */ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //.............. //1、从请求头中获取令牌 String token = request.getHeader(jwtProperties.getAdminTokenName()); //2、校验令牌 try { log.info("jwt校验:{}", token); Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token); Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString()); log.info("当前员工id：", empId); //3、通过，放行 return true; } catch (Exception ex) { //4、不通过，响应401状态码 response.setStatus(401); return false; } } }ThreadLocal传递信息ThreadLocal 并不是一个Thread，而是Thread的局部变量。ThreadLocal为每个线程提供单独一份存储空间，具有线程隔离的效果，只有在线程内才能获取到对应的值，线程外则不能访问。public void set(T value) 设置当前线程的线程局部变量的值public T get() 返回当前线程所对应的线程局部变量的值public void remove() 移除当前线程的线程局部变量初始工程中已经封装了 ThreadLocal 操作的工具类：在sky-common模块package com.sky.context; public class BaseContext { public static ThreadLocal<Long> threadLocal = new ThreadLocal<>(); public static void setCurrentId(Long id) { threadLocal.set(id); } public static Long getCurrentId() { return threadLocal.get(); } public static void removeCurrentId() { threadLocal.remove(); } }在拦截器中解析出当前登录员工id，并放入线程局部变量中：在sky-server模块中，拦截器：package com.sky.interceptor; @Component @Slf4j public class JwtTokenAdminInterceptor implements HandlerInterceptor { @Autowired private JwtProperties jwtProperties; public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //............................. //2、校验令牌 try { //................. Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token); Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString()); log.info("当前员工id：", empId); /////将用户id存储到ThreadLocal//////// BaseContext.setCurrentId(empId); //////////////////////////////////// //3、通过，放行 return true; } catch (Exception ex) { //...................... } } }在Service中获取线程局部变量中的值：public void save(EmployeeDTO employeeDTO) { //............................. //设置当前记录创建人id和修改人id employee.setCreateUser(BaseContext.getCurrentId());//目前写个假数据，后期修改 employee.setUpdateUser(BaseContext.getCurrentId()); employeeMapper.insert(employee); }员工分页查询设计DTO类请求参数类型为Query，不是json格式提交，在路径后直接拼接。/admin/employee/page?name=zhangsan返回数据中records数组中使用Employee实体类对属性进行封装。package com.sky.dto; import lombok.Data; import java.io.Serializable; @Data public class EmployeePageQueryDTO implements Serializable { //员工姓名 private String name; //页码 private int page; //每页显示记录数 private int pageSize; }封装PageResult后面所有的分页查询，统一都封装为PageResult对象。在sky-common模块package com.sky.result; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; import java.util.List; @Data @AllArgsConstructor @NoArgsConstructor public class PageResult implements Serializable { private long total; //总记录数 private List records; //当前页数据集合 }员工信息分页查询后端返回的对象类型为: Result<PageResult>Controller层在sky-server模块中，com.sky.controller.admin.EmployeeController中添加分页查询方法。@GetMapping("/page") @ApiOperation("员工分页查询") public Result<PageResult> page(EmployeePageQueryDTO employeePageQueryDTO){ log.info("员工分页查询，参数为：{}", employeePageQueryDTO); PageResult pageResult = employeeService.pageQuery(employeePageQueryDTO);//后续定义 return Result.success(pageResult); }Service层接口在EmployeeService接口中声明pageQuery方法：PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO);Service层实现类在EmployeeServiceImpl中实现pageQuery方法：public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) { // select * from employee limit 0,10 //开始分页查询 PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize()); Page<Employee> page = employeeMapper.pageQuery(employeePageQueryDTO);//后续定义 long total = page.getTotal(); List<Employee> records = page.getResult(); return new PageResult(total, records); }注意：此处使用 mybatis 的分页插件 PageHelper 来简化分页代码的开发。底层基于 mybatis 的拦截器实现。故在pom.xml文中添加依赖(初始工程已添加)<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>${pagehelper}</version> </dependency>Mapper层在 EmployeeMapper 中声明 pageQuery 方法：Page<Employee> pageQuery(EmployeePageQueryDTO employeePageQueryDTO);在 src/main/resources/mapper/EmployeeMapper.xml 中编写SQL： select * from employee <where> <if test="name != null and name != ''"> and name like concat('%',#{name},'%') </if> </where> order by create_time desc SpringMVC的消息转换器处理日期格式操作时间字段显示有问题可以在WebMvcConfiguration中扩展SpringMVC的消息转换器，统一对日期类型进行格式处理protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) { log.info("扩展消息转换器..."); //创建一个消息转换器对象 MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); //需要为消息转换器设置一个对象转换器，对象转换器可以将Java对象序列化为json数据 converter.setObjectMapper(new JacksonObjectMapper()); //将自己的消息转化器加入容器中 converters.add(0,converter); }时间格式定义，sky-common模块中package com.sky.json; public class JacksonObjectMapper extends ObjectMapper { public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm"; } }根据id查询员工信息 Controller层在 EmployeeController 中创建 getById 方法：@GetMapping("/{id}") @ApiOperation("根据id查询员工信息") public Result<Employee> getById(@PathVariable Long id){ Employee employee = employeeService.getById(id); return Result.success(employee); }其中，@PathVariable 是一个注解，表示这个参数的值会从HTTP请求的URL路径中提取Service层在 EmployeeService 接口中声明 getById 方法：Employee getById(Long id);Service层实现类在 EmployeeServiceImpl 中实现 getById 方法：public Employee getById(Long id) { Employee employee = employeeMapper.getById(id); return employee; }Mapper层在 EmployeeMapper 接口中声明 getById 方法：/** * 根据id查询员工信息 * @param id * @return */ @Select("select * from employee where id = #{id}") Employee getById(Long id);编辑员工信息 注:因为是修改功能，请求方式可设置为PUT。Controller层在 EmployeeController 中创建 update 方法：@PutMapping @ApiOperation("编辑员工信息") public Result update(@RequestBody EmployeeDTO employeeDTO){ log.info("编辑员工信息：{}", employeeDTO); employeeService.update(employeeDTO); return Result.success(); }Service层接口在 EmployeeService 接口中声明 update 方法：void update(EmployeeDTO employeeDTO);Service层实现类在 EmployeeServiceImpl 中实现 update 方法：public void update(EmployeeDTO employeeDTO) { Employee employee = new Employee(); BeanUtils.copyProperties(employeeDTO, employee); employee.setUpdateTime(LocalDateTime.now()); employee.setUpdateUser(BaseContext.getCurrentId()); employeeMapper.update(employee); }Mapper层在 EmployeeMapper 接口中声明 update 方法：void update(Employee employee);在 EmployeeMapper.xml 中编写SQL：<update id="update" parameterType="Employee"> update employee <set> <if test="name != null">name = #{name},</if> <if test="username != null">username = #{username},</if> <if test="password != null">password = #{password},</if> <if test="phone != null">phone = #{phone},</if> <if test="sex != null">sex = #{sex},</if> <if test="idNumber != null">id_Number = #{idNumber},</if> <if test="updateTime != null">update_Time = #{updateTime},</if> <if test="updateUser != null">update_User = #{updateUser},</if> <if test="status != null">status = #{status},</if> </set> where id = #{id} </update>
实战篇Redis基于Redis实现短信登录流程分析生成验证码接受前端发送的数据，后端进入验证码发送流程检验手机格式若正确，随机生成验证码将验证码存于Redis中登录用户填入验证码后发送请求，后端进入登录流程检验手机号格式利用手机号从Redis获取相关联的验证码检验验证码是否和用户输入的验证码是否相同相同，则查询是否存在账户，若无，则创建一个新用户若有，则将用户的信息封装为DTO存入Redis中生成token,用于验证用户返回token给前端拦截器获取请求头中的tokenb基于token获取Redis中的用户刷新token有效期发送短信验证码@Override public Result sendCode(String phone, HttpSession session) { // 1.校验手机号格式 if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误"); } // 2.生成验证码 String code = RandomUtil.randomNumbers(6); // 3.将验证码存入Redis stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES); // 4.发送验证码 log.debug("发送短信验证码成功，手机号：{}，验证码：{}", phone, code); return Result.ok("验证码已发送"); }检验手机号格式，调用RegexUtils下的isPhoneInvalid函数，对应代码为/** * 是否是无效手机格式 * @param phone 要校验的手机号 * @return true:符合，false：不符合 */ public static boolean isPhoneInvalid(String phone){ return mismatch(phone, RegexPatterns.PHONE_REGEX); } // 校验是否不符合正则格式 private static boolean mismatch(String str, String regex){ if (StrUtil.isBlank(str)) { return true; } return !str.matches(regex); }RegexPatterns.PHONE_REGEX为对应的正则表达式，mismatch函数先判断该字符串是否为空，若为空，则不符合正则格式，返回truematches是 Java 中 String 类的一个方法，用于判断字符串是否与给定的正则表达式模式匹配发送业务功能暂未实现，先模拟输出log短信验证码登录从Redis获取验证码并检验String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone); String code = loginForm.getCode(); if (cacheCode == null || !cacheCode.toString().equals(code)) { return Result.fail("验证码错误"); }调用stringRedisTemplate中的opsForValue()获取验证码，若不一致，则报错存储获取到的用户信息User user = query().eq("phone", phone).one(); // 不存在，创造新用户并保存 if (user == null) { user = createUserWithPhone(phone); } // 将User对象转为HashMap存储 UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create() .setIgnoreNullValue(true) .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));因为stringRedisTemplate我们所用的为其中对String类型操作的函数,而DTO变量类型不统一，直接传递会报错，所以需要先转换BeanUtil.beanToMap() 将Java Bean对象的属性转换为键值对（Key-Value）形式的MapCopyOptions.create()用来自定义转换规则的配置setIgnoreNullValue(true)忽略源对象中值为null的属性，这些属性不会放入Map中setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()) 为对所有非空属性值进行统一处理，将其转换为字符串生成Token并存储String token = UUID.randomUUID().toString(true); String tokenKey = LOGIN_USER_KEY + token; stringRedisTemplate.opsForHash().putAll(tokenKey, userMap); // 7.4.设置token有效期 stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES); // 8.返回token return Result.ok(token);这样，我们只需要有token，就可以从数据库中找到相关联的用户信息拦截器拦截器我们需要配置两项一项在任意页面中，用来获取token，查询是否能找到相应数据，若能找到，则保存在ThreadLocal，并放行另一项则只在需要登陆的路径中拦截，如果能从ThreadLocal中查询到相关数据，则放行配置拦截器所有拦截器public class RefreshTokenInterceptor implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1.获取请求头中的token String token = request.getHeader("authorization"); if (StrUtil.isBlank(token)) { return true; } // 2.基于TOKEN获取redis中的用户 String key = LOGIN_USER_KEY + token; Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key); // 3.判断用户是否存在 if (userMap.isEmpty()) { return true; } // 5.将查询到的hash数据转为UserDTO UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); // 6.存在，保存用户信息到 ThreadLocal UserHolder.saveUser(userDTO); // 7.刷新token有效期 stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES); // 8.放行 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 移除用户 UserHolder.removeUser(); } }登录拦截器public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1.判断是否需要拦截（ThreadLocal中是否有用户） if (UserHolder.getUser() == null) { // 没有，需要拦截，设置状态码 response.setStatus(401); // 拦截 return false; } // 有用户，则放行 return true; } }注册拦截器public class MvcConfig implements WebMvcConfigurer { @Resource private StringRedisTemplate stringRedisTemplate; @Override public void addInterceptors(InterceptorRegistry registry) { // 登录拦截器 registry.addInterceptor(new LoginInterceptor()) .excludePathPatterns( "/shop/**", "/voucher/**", "/shop-type/**", "/upload/**", "/blog/hot", "/user/code", "/user/login") .order(1); // token刷新的拦截器 registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0); } }拦截器如果是自己new出来的（如LoginInterceptor），Spring容器不会管理它，因此无法通过@Autowired或@Resource等注解实现依赖注入，因此若在拦截器内部使用@Resource，注解会被忽略，导致注入失败而MvcConfig 是Spring管理的配置类（含 @Configuration 注解），其字段可通过 @Resource 正常注入通过构造函数传参是少数可行的依赖注入方式之一，同时StringRedisTemplate 作为单例Bean，通过配置类注入可被多个拦截器复用，避免重复创建店铺查询缓存流程分析添加店铺缓存收到用户端发来的请求后，先从Redis中读取缓存若读取到，则直接返回若未读取到，在数据库中查找对应店铺若读取到对应店铺，则将数据存在Redis中，否则返回错误实现商铺和缓存数据双写一致收到用户修改店铺请求后，先修改数据库再修改好数据库后，删除缓存解决缓存穿透若未从缓存中查询到数据，且数据库中也未数据中查询到，就将一个空值写入到Redis中解决缓存击穿根据前端请求从Redis中查询缓存若未查询到，则返回空若查询到，判断缓存是否过期若未过期，直接返回商品信息若过期了，判断是否有互斥锁，若已有，返回旧信息若无，开启独立线程，根据id查询数据库，将数据写入Redis释放互斥锁添加店铺缓存public Shop queryById(Long id) { String key = CACHE_SHOP_KEY + id; //从Redis查询缓存 String json = stringRedisTemplate.opsForValue().get(key); //存在则直接返回 if (StrUtil.isNotBlank(json)) { Shop shop=JSONUtil.toBean(shopJson,Shop.class); } //不存在，则根据id查询数据库 Shop shop=getById(id); if (shop==null) { return Result.fail("店铺不存在"); } //存在，则写入Redis stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop)); return Result.ok(shop); }stringRedisTemplate 操作的是字符串类型的数据，当程序调用 stringRedisTemplate.opsForValue().get(key) 时，返回的是 String 类型的JSON字符串（例如：{"id":1,"name":"Shop A"}），业务逻辑中需要操作的是 Shop 类的实例（如访问其属性 shop.getName()），而非原始字符串，因此必须将JSON字符串转换为Java对象，否则无法直接使用序列化（写入Redis时）： 通过 JSONUtil.toJsonStr(shop) 将 Shop 对象转为JSON字符串，以便存入Redis（例如：stringRedisTemplate.set(key, JSON字符串)）反序列化（读取Redis时）： 通过 JSONUtil.toBean(shopJson, Shop.class) 将JSON字符串还原为 Shop 对象，使程序能直接操作对象的属性和方法实现商铺和缓存与数据库双写一致由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是:用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;怎么解决呢？有如下几种方案Cache Aside Pattern 人工编码方式：缓存调用者在更新完数据库后再去更新缓存，也称之为双写方案Read/Write Through Pattern : 由系统本身完成，数据库与缓存的问题交由系统本身去处理Write Behind Caching Pattern ：调用者只操作缓存，其他线程去异步处理数据库，实现最终一致删除缓存还是更新缓存？更新缓存：每次更新数据库都更新缓存，无效写操作较多删除缓存：更新数据库时让缓存失效，查询时再更新缓存如何保证缓存与数据库的操作的同时成功或失败？单体系统，将缓存与数据库操作放在一个事务分布式系统，利用TCC等分布式事务方案先操作缓存还是先操作数据库？先删除缓存，再操作数据库先操作数据库，再删除缓存若先删除缓存，再操作数据库，当两个线程并发时，线程1把缓存删了后，线程2查询缓存为空，查询数据库，写入的为旧数据public Result updata(Shop shop) { Long id = shop.getId(); if (id == null) { return Result.fail("店铺ID不能为空"); } updateById(shop); stringRedisTemplate.delete(CACHE_SHOP_KEY + id); return Result.ok(); }当我们修改了数据之后，然后把缓存中的数据进行删除，查询时发现缓存中没有数据，则会从mysql中加载最新的数据，从而避免数据库和缓存不一致的问题缓存穿透缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在，这样缓存永远不会生效，这些请求都会打到数据库当我们客户端不存在数据时，请求会访问到数据库，但是和数据库中也没有数据，这个数据穿透了缓存，直击数据库数据库承载能力没有Redis那么高，所以在高并发请求下容易出问题解决方案是如果不存在，就返回一个空值，这个空值有效期比较短，这样，短时间内下次访问这个不存在的数据时，redis就能找到这个数据，不会进入数据库中public Shop queryWithPassThrough(Long id) { String key = CACHE_SHOP_KEY + id; //查询Redis String shopJson = stringRedisTemplate.opsForValue().get(key); //若不为空，直接返回 if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } if (shopJson != null) { return null; } // 数据库查询 Shop shop = getById(id); if (shop == null) { //若数据库为空，将空值存入Redis中 stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } //若数据库不为空，存入缓存 stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); return shop; }缓存击穿缓存击穿问题也叫热点Key问题，就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了，无数的请求访问会在瞬间给数据库带来巨大的冲击。假设线程1在查询缓存之后，本来应该去查询数据库，然后把这个数据重新加载到缓存的，此时只要线程1走完这个逻辑，其他线程就都能从缓存中加载这些数据了但是假设在线程1没有走完的时候，后续的线程2，线程3，线程4同时过来访问当前这个方法， 那么这些线程都不能从缓存中查询到数据，接着同一时间去访问数据库，同时的去执行数据库代码，对数据库访问压力过大互斥锁因为锁能实现互斥性。假设线程过来，只能一个人一个人的来访问数据库，从而避免对于数据库访问压力过大，但这也会影响查询的性能，因为此时会让查询的性能从并行变成了串行，我们可以采用tryLock方法 + double check来解决这样的问题。假设现在线程1过来访问，他查询缓存没有命中，但是此时他获得到了锁的资源，那么线程1就会一个人去执行逻辑假设现在线程2过来，线程2在执行过程中，并没有获得到锁，那么线程2就可以进行到休眠，直到线程1把锁释放后，线程2获得到锁，然后再来执行逻辑，此时就能够从缓存中拿到数据了。[album type="photos"][/album]//获取锁 private boolean tryLock(String key) { Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } //解锁 private void unlock(String key) { stringRedisTemplate.delete(key); }操作代码：public Shop queryWithMutex(Long id) { String key = CACHE_SHOP_KEY + id; // 1、从redis中查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get("key"); // 2、判断是否存在 if (StrUtil.isNotBlank(shopJson)) { // 存在,直接返回 return JSONUtil.toBean(shopJson, Shop.class); } //判断命中的值是否是空值 if (shopJson != null) { //返回一个错误信息 return null; } // 4.实现缓存重构 //4.1 获取互斥锁 String lockKey = "lock:shop:" + id; Shop shop = null; try { boolean isLock = tryLock(lockKey); // 4.2 判断否获取成功 if(!isLock){ //4.3 失败，则休眠重试 Thread.sleep(50); return queryWithMutex(id); } //4.4 成功，根据id查询数据库 shop = getById(id); // 5.不存在，返回错误 if(shop == null){ //将空值写入redis stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES); //返回错误信息 return null; } //6.写入redis stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES); }catch (Exception e){ throw new RuntimeException(e); } finally { //7.释放互斥锁 unlock(lockKey); } return shop; }核心思路就是利用redis的setnx方法来表示获取锁，该方法含义是redis中如果没有这个key，则插入成功，返回1，在stringRedisTemplate中返回true如果有这个key则插入失败，则返回0，在stringRedisTemplate返回false我们可以通过true，或者是false，来表示是否有线程成功插入key，成功插入的key的线程我们认为他就是获得到锁的线程逻辑过期我们之所以会出现这个缓存击穿问题，主要原因是在于我们对key设置了过期时间，假设我们不设置过期时间，其实就不会有缓存击穿的问题我们把过期时间设置在 redis的value中，这个过期时间并不会直接作用于redis，而是我们后续通过逻辑去处理假设线程1去查询缓存，然后从value中判断出来当前的数据已经过期了，此时线程1去获得互斥锁，开启一个线程去进行以前的重构数据的逻辑，直到新开的线程完成这个逻辑后，才释放锁，而线程1直接进行返回假设现在线程3过来访问，由于线程线程2持有着锁，所以线程3无法获得锁，线程3也直接返回数据只有等到新开的线程2把重建数据构建完后，其他线程才能走返回正确的数据[album type="photos"][/album]@Data public class RedisData { private LocalDateTime expireTime; private Object data; }新建一个实体类，用来存储逻辑过期数据private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); public Shop queryWithLogicalExpire( Long id ) { String key = CACHE_SHOP_KEY + id; // 1.从redis查询商铺缓存 String json = stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 if (StrUtil.isBlank(json)) { // 3.存在，直接返回 return null; } // 4.命中，需要先把json反序列化为对象 RedisData redisData = JSONUtil.toBean(json, RedisData.class); Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); LocalDateTime expireTime = redisData.getExpireTime(); // 5.判断是否过期 if(expireTime.isAfter(LocalDateTime.now())) { // 5.1.未过期，直接返回店铺信息 return shop; } // 5.2.已过期，需要缓存重建 // 6.缓存重建 // 6.1.获取互斥锁 String lockKey = LOCK_SHOP_KEY + id; boolean isLock = tryLock(lockKey); // 6.2.判断是否获取锁成功 if (isLock){ CACHE_REBUILD_EXECUTOR.submit( ()->{ try{ //重建缓存 this.saveShop2Redis(id,20L); }catch (Exception e){ throw new RuntimeException(e); }finally { unlock(lockKey); } }); } // 6.4.返回过期的商铺信息 return shop; }当用户开始查询redis时，判断是否命中如果没有命中则直接返回空数据，不查询数据库而一旦命中后，将value取出，判断value中的过期时间是否满足如果没有过期，则直接返回redis中的数据如果过期，则在开启独立线程后直接返回之前的数据，独立线程去重构数据，重构完成后释放互斥锁封装Redis工具类基于StringRedisTemplate封装一个缓存工具类，满足下列需求：方法1：将任意Java对象序列化为json并存储在string类型的key中，并且可以设置TTL过期时间public void set(String key, Object value, Long time, TimeUnit unit) { stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit); }方法2：将任意Java对象序列化为json并存储在string类型的key中，并且可以设置逻辑过期时间，用于处理缓存击穿问题public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) { // 设置逻辑过期 RedisData redisData = new RedisData(); redisData.setData(value); redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time))); // 写入Redis stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); }方法3：根据指定的key查询缓存，并反序列化为指定类型，利用缓存空值的方式解决缓存穿透问题public <R,ID> R queryWithPassThrough( String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){ String key = keyPrefix + id; // 1.从redis查询商铺缓存 String json = stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 if (StrUtil.isNotBlank(json)) { // 3.存在，直接返回 return JSONUtil.toBean(json, type); } // 判断命中的是否是空值 if (json != null) { // 返回一个错误信息 return null; } // 4.不存在，根据id查询数据库 R r = dbFallback.apply(id); // 5.不存在，返回错误 if (r == null) { // 将空值写入redis stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); // 返回错误信息 return null; } // 6.存在，写入redis this.set(key, r, time, unit); return r; }方法4：根据指定的key查询缓存，并反序列化为指定类型，需要利用逻辑过期解决缓存击穿问题public <R, ID> R queryWithMutex(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) { String key = keyPrefix + id; // 1.从redis查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 if (StrUtil.isNotBlank(shopJson)) { // 3.存在，直接返回 return JSONUtil.toBean(shopJson, type); } // 判断命中的是否是空值 if (shopJson != null) { // 返回一个错误信息 return null; } // 4.实现缓存重建 // 4.1.获取互斥锁 String lockKey = LOCK_SHOP_KEY + id; R r = null; try { boolean isLock = tryLock(lockKey); // 4.2.判断是否获取成功 if (!isLock) { // 4.3.获取锁失败，休眠并重试 Thread.sleep(50); return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit); } // 4.4.获取锁成功，根据id查询数据库 r = dbFallback.apply(id); // 5.不存在，返回错误 if (r == null) { // 将空值写入redis stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); // 返回错误信息 return null; } // 6.存在，写入redis this.set(key, r, time, unit); } catch (InterruptedException e) { throw new RuntimeException(e); }finally { // 7.释放锁 unlock(lockKey); } // 8.返回 return r; }优惠券秒杀优惠券全局唯一ID生成当用户抢购时，就会生成订单并保存到tb_voucher_order这张表中，而订单表如果使用数据库自增ID就存在一些问题：id的规律性太明显受单表数据量的限制为了增加ID的安全性，我们可以不直接使用Redis自增的数值，而是拼接一些其它信息：ID的组成部分：符号位：1bit，永远为0时间戳：31bit，以秒为单位，可以使用69年序列号：32bit，秒内的计数器，支持每秒产生2^32个不同ID@Component public class RedisIdWorker { /** * 开始时间戳 */ private static final long BEGIN_TIMESTAMP = 1753724880L; /** * 序列号的位数 */ private static final int COUNT_BITS = 32; 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.自增长 long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date); // 3.拼接并返回 return timestamp << COUNT_BITS | count; } }now.toEpochSecond()是将now转换为时间戳若直接调用 now.toEpochSecond()（无参数），系统会默认使用本地时区（如 +08:00）计算时间戳例如，北京时间 2025-07-29T10:00:00 会被视为 UTC+8 时间，其 UTC 时间实为 2025-07-29T02:00:00Z这样会导致时间戳减少 28,800 秒（8小时），ZoneOffset.UTC设置时区能解决这个问题添加优惠券每个店铺都可以发布优惠券，分为平价券和特价券。平价券可以任意购买，而特价券需要秒杀抢购：tb_voucher：优惠券的基本信息，优惠金额、使用规则等tb_seckill_voucher：优惠券的库存、开始抢购时间，结束抢购时间。特价优惠券才需要填写这些信息平价卷由于优惠力度并不是很大，所以是可以任意领取而代金券由于优惠力度大，所以像第二种卷，就得限制数量，从表结构上也能看出，特价卷除了具有优惠卷的基本信息以外，还具有库存，抢购时间，结束时间等等字段新增普通卷@PostMapping public Result addVoucher(@RequestBody Voucher voucher) { voucherService.save(voucher); return Result.ok(voucher.getId()); }新增秒杀卷代码VoucherController@PostMapping("seckill") public Result addSeckillVoucher(@RequestBody Voucher voucher) { voucherService.addSeckillVoucher(voucher); return Result.ok(voucher.getId()); }VoucherServiceImpl@Override @Transactional public void addSeckillVoucher(Voucher voucher) { // 保存优惠券 save(voucher); // 保存秒杀信息 SeckillVoucher seckillVoucher = new SeckillVoucher(); seckillVoucher.setVoucherId(voucher.getId()); seckillVoucher.setStock(voucher.getStock()); seckillVoucher.setBeginTime(voucher.getBeginTime()); seckillVoucher.setEndTime(voucher.getEndTime()); seckillVoucherService.save(seckillVoucher); // 保存秒杀库存到Redis中 stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString()); }秒杀下单使用Lua脚本解决多条命令原子性问题static { SECKILL_SCRIPT = new DefaultRedisScript<>(); //创建脚本执行器实例 SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); // 加载Lua脚本 SECKILL_SCRIPT.setResultType(Long.class); // 声明返回类型 } @Override public Result seckillVoucher(Long voucherId) { Long userId = UserHolder.getUser().getId(); long orderId = redisIdWorker.nextId("order"); // 1.执行lua脚本 Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString(), String.valueOf(orderId)); int r = result.intValue(); // 2.判断结果是否为0 if (r != 0) { // 不为0 ，代表没有购买资格 return Result.fail(r == 1 ? "库存不足" : "不能重复下单"); } //返回订单id return Result.ok(orderId); }Redis提供了Lua脚本功能，在一个脚本中编写多条Redis命令，确保多条命令执行时的原子性参数类型说明SECKILL_SCRIPTRedisScript预加载的 Lua 脚本对象，包含秒杀业务逻辑Collections.emptyList()ListKEYS 列表，这里为空表示脚本不需要操作特定 KeyvoucherId.toString()StringARGV：优惠券 IDuserId.toString()StringARGV：用户 IDString.valueOf(orderId)StringARGV：订单 IDlua脚本为local voucherId = ARGV[1] local userId = ARGV[2] local orderId = ARGV[3] local stockKey = "seckill:stock:" .. voucherId -- 存储库存数量 local orderkey = "seckill:order:" .. voucherId -- 存储已购买用户ID集合 if (tonumber(redis.call("get", stockKey)) <= 0) then return 1 -- 库存不足 end if (redis.call("sismember", orderkey, userId) == 1) then return 2 -- 重复购买 end redis.call("incrby", stockKey, -1) -- 库存-1 redis.call("sadd", orderkey, userId) -- 添加用户到购买集合 redis.call("xadd", "stream.orders", "*", "userId", userId, "voucherId", voucherId, "id", orderId) return 0;使用redis.call("get")获取当前库存，tonumber()将字符串转为数字（Redis返回的是字符串）返回1表示库存不足，返回状态码2表示重复购买使用Redis Stream发送订单消息消息包含用户ID、优惠券ID和订单ID*表示自动生成消息ID创建订单引入依赖：<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.13.6</version> </dependency>配置Redisson客户端：@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient(){ // 配置 Config config = new Config(); config.useSingleServer().setAddress("redis://192.168.150.101:6379") .setPassword("123321"); // 创建RedissonClient对象 return Redisson.create(config); } }注入RedissonClient@Resource private RedissonClient redissonClient; private void createVoucherOrder(VoucherOrder voucherOrder) { Long userId = voucherOrder.getUserId(); Long voucherId = voucherOrder.getVoucherId(); // 创建锁对象 RLock redisLock = redissonClient.getLock("lock:order:" + userId); // 尝试获取锁 boolean isLock = redisLock.tryLock(); // 判断 if (!isLock) { // 获取锁失败，直接返回失败或者重试 log.error("不允许重复下单！"); return; } try { //查询订单 int count = query().eq("user_id", userId).eq ("voucher_id", voucherId).count(); // 判断是否存在 if (count > 0) { // 用户已经购买过了 log.error("不允许重复下单！"); return; } // 扣减库存 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) { // 扣减失败 log.error("库存不足！"); return; } // 创建订单 save(voucherOrder); } finally { // 释放锁 redisLock.unlock(); } }基于Stream的消息队列整体架构Javaprivate static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor(); @PostConstruct private void init() { SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); }单线程执行器：使用 newSingleThreadExecutor() 创建单线程线程池，确保订单处理顺序性初始化触发：@PostConstruct 注解使 Spring 容器初始化后自动启动订单处理器核心处理器private class VoucherOrderHandler implements Runnable { @Override public void run() { while (true) { // 订单处理逻辑 } } }读取消息List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read( Consumer.from("g1", "c1"), // 消费者组和消费者标识 StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), // 读取配置 StreamOffset.create("stream.orders", ReadOffset.lastConsumed()) // 流位置 );Consumer.from("g1", "c1")表示从消费者组g1中，以消费者c1的身份读取消息.count(1)：每次最多读取1条消息.block(Duration.ofSeconds(2))：若无消息则阻塞等待2秒，超时返回空列表；期间有新消息立即返回"stream.orders"：目标Stream的名称（消息队列标识）ReadOffset.lastConsumed()：表示只读取该消费者组未处理的新消息（已读未ACK的消息不重复读取）消息处理if (list == null || list.isEmpty()) continue; // 无消息则跳过 // 消息转换 MapRecord<String, Object, Object> record = list.get(0);// 获取第一条消息 Map<Object, Object> value = record.getValue();// 提取消息内容（键值对形式） VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);// 将Map转换为Java Bean对象 createVoucherOrder(voucherOrder); // 创建订单 // ACK确认 stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());Bean转换：使用 Hutool 的 BeanUtil 将 Redis Hash 转换为 Java 对象业务处理：执行 createVoucherOrder 订单创建逻辑消息确认：成功处理后发送 XACK 命令stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId()) 是 Redis Stream 中消费者组（Consumer Group）确认消息处理完成的核心操作，其作用原理与参数含义如下：参数含义示例"s1"Stream 的名称，即消息队列的标识"stream.orders""g1"消费者组名称，用于管理多个消费者"order_consumers"record.getId()消息的唯一ID（由 Redis 生成，格式如 "1692632086370-0"）"1692632086370-0"通过 XACK 命令告知 Redis 服务器：指定消费者组中的消费者已成功处理该消息。确认后：消息从“未确认列表”（Pending List）移除，避免重复投递。📊 消费者组更新处理进度（last delivered ID），确保后续消息正确分配。异常处理Javacatch (Exception e) { log.error("处理订单异常", e); handlePendingList(); // 处理异常消息 }