一、短信校验登录
思路:

校验登录状态,需要限制一些未登录时的一些操作,例如:用户下载,评论。
这个地方还有一个优化,判断用户是否存在后,多个页面都需要进行用户校验,所有定义一个拦截器去拦截,后续不需要再在多个controller中添加这个代码。

拦截器的实现:(是一个工具utils)
思路:添加LoginInterceptor工具类->配置它(在config包中创建MvcConfig)->使用拦截器
1 添加LoginInterceptor工具类
public class LoginInterceptor implements HandlerInterceptor {
// preHandle → Controller → postHandle → 视图渲染 → afterCompletion
@Override //在controller之前执行 用处:登录/权限校验
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1 获取session
HttpSession session = request.getSession();
//2 获取session中的用户
Object user = session.getAttribute("user");
//3 判断用户是否存在
if (user == null){
//4 不存在,未登录,需要跳转到登录页面
return false;
}
//将用户保存到ThreadLocal
UserHolder.saveUser((UserDTO) user);
return true;
}
// @Override //在controller之后执行 用途:资源回收、ThreadLocal 清理(特别重要)。
// public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
// }
@Override //视图渲染之后,返回给用户之前执行
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户
UserHolder.removeUser();
}
2 创建MvcConfig
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).
excludePathPatterns(//这些不需要进行用户校验
"/user/login",
"/user/code",
"/user/logout",
"shop/**",
"shop-type/**",
"blog/**",
"voucher/**"
);
}
}
3 使用拦截器
@GetMapping("/me")
public Result me(){
// TODO 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
其中优化点:隐藏cookie中的一些user敏感信息
方法:添加一个UserDTO类,只保留id、、nickName、icon信息,然后放入session中
UserDTO user = query().eq("phone",phone).one();
使用session实现的弊端:

为什么要使用redis来实现?
当请求变多时,会增加tomcat服务器,使用负载均衡(将一台服务器的压力分散到多台服务器中)缓解压力。最初解决方案:复制Session给多个服务器,但是会带来一些问题:每个服务器中存在相同内容,出现内存问题;服务器之间相互复制session时有延迟,也会出现数据不一致的问题。
所以使用redis。
如何使用redis来实现?
修改原来的业务流程,
1、在发送验证码过程中,改用redis保存验证码。考虑数据结构:redis是k-v形式,
v:验证码的值使用String类型即可
k:使用手机号(原因:session实现时,每个用户都有唯一的sessionID,现在用户数据放入redis中,数据是共享的,需要有唯一的ID即手机号;校验手机号方便)
使用redis,添加:
@Resource
private StringRedisTemplate stringRedisTemplate;
将4修改成:使用redis保存验证码
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL , TimeUnit.MINUTES);
注:在实际业务中,k值一般加前缀加以区分,和将这些常量单独写入一个类使用,显得“优雅”!
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
2、在验证码登录注册过程中,使用redis保存用户,再将K返回给前端。考虑数据结构:redis保存对象方式:
1)String 用JSON字符串来保存

2)Hash 将对象中的字段独立存储,可以单独修改对象字段,占用内存更小

v:使用Hash类型保存
k:生成随机字符串token(不使用手机号原因:k会跟之前的sessionID一样,保存到浏览器中,数据不安全)
//2 从redis中获取验证码验证
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
//5 保存用户到redis中
//5.1 生成随机token
String token = UUID.randomUUID().toString(true);
//5.2 将用户对象转换成Hash
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);//与之前一样需要将浏览器中私密信息隐藏(优化)
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);//putAll中需要使用Map,一次性放入对象的多个字段和值
//5.3 保存
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY,userMap);
//5.4 设置有效期,避免用户频繁登录,生成新的token,导致内存不必要的占用(优化)
stringRedisTemplate.expire(LOGIN_USER_KEY + token,LOGIN_USER_TTL, TimeUnit.MINUTES);
//6 返回token给前端
return Result.ok(token);
3)在登录校验时,使用K去获取用户,以及刷新token
使用俩个拦截器,流程如下:第一个只负责保存登录用户和刷新token,第二个负责检查用户是否存在

//注册器
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//登录拦截器
registry.addInterceptor(new LoginInterceptor()).
excludePathPatterns(//这些不需要进行用户校验
"/user/login",
"/user/code",
"/user/logout",
"/shop/**",
"/shop-type/**",
"/blog/hot",
"/voucher/**",
"/upload/**"
).order(1);
//刷新token拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
}
}
//RefreshTokenInterceptor
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//登录拦截器
registry.addInterceptor(new LoginInterceptor()).
excludePathPatterns(//这些不需要进行用户校验
"/user/login",
"/user/code",
"/user/logout",
"/shop/**",
"/shop-type/**",
"/blog/hot",
"/voucher/**",
"/upload/**"
).order(1);
//刷新token拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
}
}
//LoginInterceptor
@Override //在controller之前执行 用处:登录/权限校验
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (UserHolder.getUser()==null) {
response.setStatus(401);
return false;
}
return true;
}
启发:当我写一个工具类不想交给Spring,我又想间接注入spring中的bean时,就可以创建一个他的配置类,然后将配置类交给spring管理,最后通过构造器进行简介注入,即应用IOC思想。
二、将原有的商品CURD使用redis缓存
给商户添加缓存:

@Override
public Result queryShopById(Long id) {
//1 从redis查询商铺信息
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2 如果存在,直接返回
/**区别:if (shopJson!=null) if (!shopJson.isEmpty())
* shopJson != null
* 只判断是否为 null。
* "": true(非 null)
* " ": true(非 null)
* 可能误把空串/空白串当作命中。
* !shopJson.isEmpty()
* 判断长度是否>0,但前提是变量不是 null。若为 null 会抛 NullPointerException。
* null: 抛 NPE
* "": false
* " ": true(长度>0),会把全空白当命中。
* StrUtil.isNotBlank(shopJson)(Hutool)
* 同时判断非 null、长度>0、去掉首尾空白后长度>0。
* null: false
* "": false
* " ": false
* "abc": true
* 最安全、最符合“命中有效值”的语义。
* */
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//3 如果不存在,从数据库查询
Shop shop = getById(id);
//4 如果数据库不存在,返回错误信息
if (shop == null) {
return Result.fail("商铺不存在");
}
//5 如果数据库存在,写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));
//6 返回商铺信息
return Result.ok(shop);
}


缓存前:366ms —> 缓存后:43ms
三、缓存策略

主动更新策略:
- 自定义更新策略(推荐,满足大部分要求,缺点:维护成本大)
- 调用服务(没什么现成的服务,维护困难)
- 线程异步写回(优点:只需要写回最后一次修改内容,缺点:没写回前,数据不一致,如果redis宕机,缓存数据全部丢失)


问题三原因:

总结:

实战:使用超时剔除和主动更新策略(先改数据库,再删缓存)实现双向写一致

//5 如果数据库存在,写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
return shopService.update(shop);
}
@Override
@Transactional//事务保证1 2 原子性
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("商铺id不能为空");
}
//1 修改数据库
updateById(shop);
//2 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
return Result.ok();
}
四、缓存穿透
什么是缓存穿透?
指发送无效的请求,到redis,且缓存未命中,直接到数据库,返回空。
如何解决缓存穿透?
主流有以下几种:
1)缓存空对象(被动)
优点:实现简单暴力,维护方便
缺点:额外内存开销(缓存大量null(1,null)、(2,null)…通过设置TTL缓解);
短时间内数据不一致(缓存后(1,null),插入(1,“张三”),通过插入后更新缓存解决)

举例:请求(1)-> redis 未命中 -> 数据库未命中 -> 缓存null(1,“”)
再次请求 -> 缓存命中
2)布隆过滤器(被动)
优点:内存占用少
缺点:实现复杂、存在偏差(会拦截所有没有的数据,但是对于存在的数据判断不准确
【例子:请求->布隆过滤器判断数据存在->redis未命中->数据库未命中】)

3)增加id复杂性,做好id格式校验即可(主动)
4)做好热点参数等限流(主动)
实战:在查询商铺中防止缓存穿透
public Result queryShopById(Long id) {
//1 从redis查询商铺信息
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2 如果存在,直接返回
//非空非null
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//非null,即空,返回错误信息
/**
isEmpty() 只检查字符串长度是否为 0,但前提是对象不能为 null
当 shopJson 为 null 时,调用 isEmpty() 方法会抛出 NullPointerException
*/
if (shopJson != null){//或shopJson != null && !shopJson.isEmpty()
return Result.fail("店铺不存在");
}
//3 如果不存在,从数据库查询
Shop shop = getById(id);
//4 如果数据库不存在,返回错误信息
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("商铺不存在");
}
//5 如果数据库存在,写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
//6 返回商铺信息
return Result.ok(shop);
}
五、缓存雪崩(集体休眠)
什么是缓存雪崩?
同一时间段内,大量key失效或Redis宕机,导致大量请求直接到达数据库,给数据库带来巨大压力。
如何解决缓存雪崩?
1)给key设置不同的TTL值(做预热时,批量导入数据到缓存,key会同时失效,给key的TTL添加不同的随机值即可)
2)利用redis集群提高服务的可用性(Spring Cloud)
3)给缓存业务添加降级限流策略(快速失败,redis宕机后,如果是无效请求直接返回错误 Spring Cloud)
4)给业务添加多级缓存(Spring Cloud)
六、缓存击穿(热点key问题)(单点爆破)
什么是缓存击穿?
被高并发请求和缓存重建复杂的key突然失效了,无数请求直接到达数据库,造成巨大压力
如何解决缓存击穿?(本质:如何处理重建缓存前,这段时间内的并发问题)
1)互斥锁【性能换可用性】(通过锁,让一个线程重建缓存,其他线程等待)

实战:

//在redis中有setnx实现互斥锁
help setnx
SETNX key value
summary: Set the value of a key, only if the key does not exist
since: 1.0.0
group: string
127.0.0.1:6379> setnx lock 1
(integer) 1
127.0.0.1:6379> get lock
127.0.0.1:6379> setnx lock 2
(integer) 0
127.0.0.1:6379> setnx lock 3
(integer) 0
127.0.0.1:6379> get lock
"1"
代码:
public Result queryShopById(Long id) {
//缓存穿透
// Shop shop = queryWithPassThrough(id);
//缓存击穿(互斥锁)
Shop shop = queryMutex(id);
if (shop == null) {
return Result.fail("商铺不存在");
}
//6 返回商铺信息
return Result.ok(shop);
}
//缓存击穿(互斥锁)
public Shop queryMutex(Long id){
//1 从redis查询商铺信息
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2 如果存在,直接返回
//非空非null
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//非null,即空,返回错误信息
if (shopJson != null){
return null;
}
//3 如果不存在,重建缓存
// 3.1 尝试获取互斥锁
Shop shop = null;
try {
boolean isLock = tryLock(LOCK_SHOP_KEY + id);
// 3.2 判断是否获取成功
if (!isLock) {
// 3.3 如果获取失败,休眠一段时间
Thread.sleep(100);
return queryMutex(id);//递归
}
// 3.4 如果获取成功,从数据库查询
shop = getById(id);
//模拟延迟
// Thread.sleep(200);
//4 如果数据库不存在,返回错误信息
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 3.5 如果获取成功,写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 3.6 释放锁
unLock(LOCK_SHOP_KEY + id);
}
//6 返回商铺信息
return shop;
}
//尝试获取锁
public boolean tryLock(String lockKey){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
//只有当 flag == Boolean.TRUE 时返回 true;flag == null、flag == Boolean.FALSE 都返回 false。
return BooleanUtil.isTrue(flag);
}
//释放锁
public void unLock(String lockKey){
stringRedisTemplate.delete(lockKey);
}
注:获取/释放锁都要放在一个try-catch-finally中
2)逻辑过期【高并发换数据一致性】(对活动商品进行预热,提前导入缓存,永久存在,未命中的说明不是活动商品;过程:第一个线程加锁,然后让另一个线程异步重建缓存,重建好后释放锁,在重建期间,都返回旧数据)


//预热数据
public void saveShop2RedisWithLogicalExpire(Long id, Long expireSeconds) {
//1 查询数据库
Shop shop = getById(id);
//封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
//导入预热数据
@Resource
private ShopServiceImpl shopServiceimpl;
@Test
void testSaveShop(){
shopServiceimpl.saveShop2RedisWithLogicalExpire(1L, 30L);
}
//创建线程池
private static final ExecutorService CACHE_BUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//缓存击穿(逻辑过期)
public Shop queryWithLogicalExpire(Long id){
//1 从redis查询商铺信息
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2 如果不存在,直接返回
if (StrUtil.isBlank(shopJson)) {
return null;
}
//3 命中,将JSON字符串反序列化成对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
//将拿到的data对象强转成JSON字符串,再toBean
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//4 判断缓存是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
//未过期
return shop;
}
//5.1 过期,尝试获取互斥锁
boolean isLock = tryLock(LOCK_SHOP_KEY + id);
//5.2 判断锁是否拿到
if (isLock) {
//5.2.2 拿到锁,开启独立线程,重建缓存
CACHE_BUILD_EXECUTOR.submit(() -> {
try {
saveShop2RedisWithLogicalExpire(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//5.2.3 释放锁
unLock(LOCK_SHOP_KEY + id);
}
});
}
//5.2.1 未拿到锁,返回过期数据
return shop;
}
对比:

启发:封装:当我需要在已经有的entity中添加字段,可以创建一个新类包含entity类+新字段,再将值set到新类中即可
七、封装redis工具类
实战:

@Component
public class CacheClient {
@Resource
private StringRedisTemplate stringRedisTemplate;
//存入缓存
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
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));
}
//缓存穿透
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)) {
R r = JSONUtil.toBean(json, type);
return r;
}
//非null,即空,返回错误信息
if (json != null){
return null;
}
//3 如果不存在,从数据库查询
R r = dbFallback.apply(id);
//4 如果数据库不存在,返回错误信息
if (r == null) {
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//5 如果数据库存在,写入redis
this.set(key, r, time, unit);
//6 返回商铺信息
return r;
}
//创建线程池
private static final ExecutorService CACHE_BUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//缓存击穿(逻辑过期)
public <R,ID> R queryWithLogicalExpire(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.isBlank(json)) {
return null;
}
//3 命中,将JSON字符串反序列化成对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
//将拿到的data对象强转成JSON字符串,再toBean
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
//4 判断缓存是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
//未过期
return r;
}
//5.1 过期,尝试获取互斥锁
boolean isLock = tryLock(LOCK_SHOP_KEY + id);
//5.2 判断锁是否拿到
if (isLock) {
//5.2.2 拿到锁,开启独立线程,重建缓存
CACHE_BUILD_EXECUTOR.submit(() -> {
try {
//查询数据库
R r1 = dbFallback.apply(id);
//写入redis
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//5.2.3 释放锁
unLock(LOCK_SHOP_KEY + id);
}
});
}
//5.2.1 未拿到锁,返回过期数据
return r;
}
//尝试获取锁
public boolean tryLock(String lockKey){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
//只有当 flag == Boolean.TRUE 时返回 true;flag == null、flag == Boolean.FALSE 都返回 false。
return BooleanUtil.isTrue(flag);
}
//释放锁
public void unLock(String lockKey){
stringRedisTemplate.delete(lockKey);
}
}
优惠券秒杀
一、生成全局唯一ID
原因:1.数据库的自增ID容易让用户猜到一些信息(订单编号10,再下一单编号100,中间卖出90个)
2.受单表限制,后期数据过多,使用多表时,ID不唯一
DROP TABLE IF EXISTS `tb_voucher_order`;
CREATE TABLE `tb_voucher_order` (
`id` bigint(20) NOT NULL COMMENT '主键', 不是自增
`user_id` bigint(20) UNSIGNED NOT NULL COMMENT '下单的用户id',
`voucher_id` bigint(20) UNSIGNED NOT NULL COMMENT '购买的代金券id',
`pay_type` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '支付方式 1:余额支付;2:支付宝;3:微信',
`status` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
`pay_time` timestamp NULL DEFAULT NULL COMMENT '支付时间',
`use_time` timestamp NULL DEFAULT NULL COMMENT '核销时间',
`refund_time` timestamp NULL DEFAULT NULL COMMENT '退款时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Compact;
策略:(时间戳+计数器)使用redis,redis数据库也是自增的(increment),所有需要给他拼接一些信息即可

返回的结果时数字Long类型,插入数据库时速度快
在生成序列号时,将每天的日期拼接到一起,后续可以根据年月日分别进行统计订单数量
@Component
public class RedisIdWorker {
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final Long BEGIN_TIMESTAMP = 1577836800L;
//设计ID的位数
private static final Long COUNT_BITS = 32L;
public Long nextId(String strPrefix) {
//生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timeStamp = nowSecond - BEGIN_TIMESTAMP;
//生成序列号
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM-dd"));
//如果是increment("inc:" + strPrefix),redis自增最大是64位,但是这里设计的序列号只要32位,加上每天的时间,每天的Key都不一样
//redis通过increment()key去自增ID
Long count = stringRedisTemplate.opsForValue().increment("inc:" + strPrefix + ":" + date);
//拼接返回
return timeStamp << COUNT_BITS | count;
}
public static void main(String[] args) {
LocalDateTime localDateTime = LocalDateTime.of(2020, 1, 1, 0, 0);
long seconds = localDateTime.toEpochSecond(ZoneOffset.UTC);
System.out.println(seconds);
}
}
二、添加秒杀券
表设计:
普通券—>秒杀券 ,秒杀券是子类,额外添加一些字段,在后端实现中使用封装思想实现
优惠券
CREATE TABLE `tb_voucher` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`shop_id` bigint unsigned DEFAULT NULL COMMENT '商铺id',
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '代金券标题',
`sub_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '副标题',
`rules` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '使用规则',
`pay_value` bigint unsigned NOT NULL COMMENT '支付金额,单位是分。例如200代表2元',
`actual_value` bigint NOT NULL COMMENT '抵扣金额,单位是分。例如200代表2元',
`type` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '0,普通券;1,秒杀券',
`status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '1,上架; 2,下架; 3,过期',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;
秒杀券
CREATE TABLE `tb_seckill_voucher` (
`voucher_id` bigint unsigned NOT NULL COMMENT '关联的优惠券的id',
`stock` int NOT NULL COMMENT '库存',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`begin_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
`end_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '失效时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`voucher_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT COMMENT='秒杀优惠券表,与优惠券是一对一关系';
代码:
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
@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);
}
三、实现秒杀下单
1.实现下单秒杀券(超卖问题)

2.解决超卖问题
使用乐观锁(判断之前查询到的数据是否被修改)俩种方式:
版本号法:判断版本号是否发生变化

CAS法:判断库存是否发生变化

CAS法代码:
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//1.根据ID查询秒杀券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2.判断是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀未开始");
}
//3.判断是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已结束");
}
//4.判断库存
if (voucher.getStock() <= 0){
return Result.fail("库存不足");
}
//5.扣减库存
// seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();
//6.创建订单
//6.1获取订单ID
Long orderId = redisIdWorker.nextId("order");
//6.2获取用户ID
Long userId = UserHolder.getUser().getId();
//6.3获取秒杀券ID
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单
return Result.ok(orderId);
}
解释:添加 gt(“stock”, 0) 就不会超卖(举例说明)
结果:最多扣减一次,不会出现变成 -1 的超卖。
场景一:初始库存 stock = 1,同时来了两个并发请求 T1 和 T2。
两个线程可能几乎同时到达 UPDATE,但实际在数据库端会串行获得行锁(数据库执行 UPDATE 时使用的是“当前读”(current read),而不是你应用层之前读到的快照值)。
T1 执行:检查条件 stock > 0 为真(1 > 0),于是将 stock = stock - 1,结果变为 0,影响行数为 1,成功。
T2 执行:此时数据库里的 stock 已是 0,再检查 stock > 0 为假,UPDATE 不生效,影响行数为 0,失败。
四、一人限定一单

难点:释放锁时保证事务已提交;如何只锁住同一用户
代码:
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
//1.根据ID查询秒杀券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2.判断是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀未开始");
}
//3.判断是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已结束");
}
//4.判断库存
if (voucher.getStock() <= 0) {
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId();
//intern()去常量池中找与该字符串相同的值的地址返回
synchronized (userId.toString().intern()) {
//获取代理对象(事务)
/*
* “this = 目标对象(业务实现)VoucherOrderServiceImpl”,用于类内部自调用,不走 AOP,事务不生效。
“proxy = Spring 代理对象”,通过它调用才会触发事务等切面。
* */
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
//5一人一单
//5.1获取用户ID
Long userId = UserHolder.getUser().getId();
//5.2查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
//5.3判断订单是否存在
if (count > 0) {
return Result.fail("用户已购买过");
}
//6.扣减库存
// seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();
if (!success) {
return Result.fail("库存不足");
}
//7.创建订单
//7.1获取订单ID
Long orderId = redisIdWorker.nextId("order");
//7.3获取秒杀券ID
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
}
//在pom.xml中添加
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
//在HmDianPingApplication中添加
@EnableAspectJAutoProxy(exposeProxy = true) //暴露Proxy
解释:乐观锁不是多余的!!乐观锁是锁库存;悲观锁只锁了用户,不同用户直接可以并发
举例说明:
场景一:单实例,同一用户并发两次,库存=1
- 请求:U1 的 T1、T2 同时下单,落在同一个应用实例。
- 应用层表现:因为 synchronized(userId.intern()),同一用户在本实例内会串行执行。T1 先进入 createVoucherOrder,T2 阻塞等待。
- 数据库表现:
- T1 扣减库存:UPDATE … SET stock=stock-1 WHERE voucher_id=? AND stock>0 → 成功,stock 从 1→0
- T1 提交后释放锁,T2 获准进入方法,执行 UPDATE … WHERE stock>0 → 失败(受影响行数=0)
- 结论:
- 应用层锁解决的是“同一用户在本实例的并发重入”。
- CAS 仍在起作用(即便 T2被串行化,最终是否成功仍由数据库当前库存决定),两者互不干扰。
场景二:单实例,不同用户并发,库存=1
- 请求:U1 的 T1 与 U2 的 T2 同时下单,落在同一个应用实例。
- 应用层表现:不同 userId 的 intern 字符串不同,不会互斥,两条线程都会并发进入 createVoucherOrder。
- 数据库表现:
- 两条 UPDATE 几乎同时到达,数据库串行获取行锁:
- 先拿到锁的请求成功扣减 stock 1→0
- 后一个请求判断 stock>0 不成立而失败(受影响行数=0)
- 两条 UPDATE 几乎同时到达,数据库串行获取行锁:
- 结论:
- 对“不同用户”应用层没有加锁(也不该加),并发正确性完全靠 CAS 保证。
- 这说明即使没有应用层锁,CAS 也能独立保障不超卖;两者职责边界清晰,互不影响。
场景三:多实例( N 台服务器部署同一个应用),同一用户并发两次,库存=1

若叠加订单表唯一约束 (user_id, voucher_id),还可同时兜底“一人一单”。
部署:两个应用实例 A、B,均接入同一数据库。
请求:U1 的 T1 到 A 实例,T2 到 B 实例。
应用层表现:synchronized(userId.intern()) 只在进程内有效,跨实例无效,T1/T2 会并发进入各自实例的 createVoucherOrder。
数据库表现:
两条 UPDATE 同样并发竞争行锁:
先执行者扣减成功,stock 1→0
另一条 UPDATE 因 stock>0 不成立失败。
结论:
多实例时应用层锁“不生效”,但 CAS 依然独立起作用,防止超卖。
//intern()去常量池中找与该字符串相同的值的地址返回
synchronized (userId.toString().intern()) {
//获取代理对象(事务)
/*
* “this = 目标对象(业务实现)VoucherOrderServiceImpl”,用于类内部自调用,不走 AOP,事务不生效。
“proxy = Spring 代理对象”,通过它调用才会触发事务等切面。
* */
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
实现分布式锁

什么是分布式锁?
在分布式系统或集群模式下满足多线程可见且互斥的锁。
如何实现分布式锁?
主要有三种:

Redis实现思路:

- 获取锁:
– 互斥:确保只能有一个线程获取锁
– 非阻塞:尝试一次,成功返回true,失败返回false
# 添加锁,NX是互斥、EX是设置超时时间
SET lock thread1 NX EX 10
- 释放锁:
– 手动释放
– 超时释放:获取锁时添加一个超时时间
# 释放锁, 删除即可
DEL key
亮点:
1.在使用短信登录时使用redis做缓存,保障负载均衡时,多台服务器之间数据一致,
以及降低延迟缓存前:366ms —> 缓存后:43ms
2.在登录过程中,获取的用户对象user(全部信息)改成使用userDTO(自定义显示user信息),防止信息泄露,加速序列化(将对象转换成Map存入redis中)
3.设置获取对象的token的有效期,避免用户频繁登录,生成新的token,导致内存不必要的占用,为了避免“活跃用户频繁掉线”,常用“滑动过期”策略:每次用户发起已登录请求时,刷新这个 token 的 TTL,让会话持续保持活跃。
4.使用IOC思想对工具类,实现更低的耦合
5.使用超时剔除和主动更新策略(先改数据库,再删缓存)实现双向写一致




