项目简介

1
2
星策味达是一款点餐系统,项目包含后台管理系统和客户端界面,实现了员工管理、菜品及套餐展示、
订单处理等核心功能,系统还引入了优惠券秒杀、菜品评价、附近美食等增值服务,丰富了用户的点餐体验。

延迟双删,缓存过期时间

数据一致性保障:采用延迟双删和缓存过期时间,巧妙地解决了数据不一致问题,防止了脏数据残留。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
1. 延迟双删
1). 解决 “数据库更新期间,其他线程可能写入旧缓存” 的并发问题。

2). 为什么需要第二次删除?
假设线程 A 删除缓存后,在更新数据库的过程中:
线程 B 查询数据,发现缓存已被删除,于是去数据库读取旧数据(此时线程 A 的更新尚未提交)。
线程 B 将旧数据写入缓存(导致缓存中再次出现旧数据)。
线程 A 完成数据库更新后,若不再次删除缓存,后续查询会读到线程 B 写入的旧数据(脏数据)。
第二次删除可清除线程 B 误写入的旧缓存,确保后续查询能读到数据库中的新数据并更新缓存。

3). 第一次删除:打破 “旧缓存→旧数据” 的读取链路。
数据库更新:确保数据持久化到最新状态。
延迟第二次删除:清理并发场景下可能被写入的旧缓存,彻底断绝脏数据来源。
/**
* 更新用户信息(结合延迟双删)
*/
@Transactional
public void updateUser(User user) {
String cacheKey = USER_CACHE_KEY + user.getId();

// 1. 第一次删除缓存
redisTemplate.delete(cacheKey);

// 2. 更新数据库
userMapper.updateById(user);

// 3. 延迟第二次删除(确保数据库更新后清除可能的旧缓存)
cacheUpdatePool.submit(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500);
redisTemplate.delete(cacheKey);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}

2. 缓存过期时间
1). 创建实体类:
// 带逻辑过期时间的缓存实体
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LogicalExpireCache<T> {
private T data; // 实际业务数据
private long expireTime; // 逻辑过期时间(毫秒时间戳)
}
2). 再存入Redis时:
/**
* 从数据库查询并写入缓存(带逻辑过期)
*/
private User queryDbAndWriteCache(Long id, String cacheKey) {
User user = userMapper.selectById(id);
if (user != null) {
// 计算逻辑过期时间(当前时间 + 30分钟)
long expireTime = System.currentTimeMillis() + LOGICAL_EXPIRE; //LOGICAL_EXPIRE是常量
LogicalExpireCache<User> cacheObj = new LogicalExpireCache<>(user, expireTime);
// 写入缓存(Redis层面不设置过期时间)
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cacheObj));
}
return user;
}
3). 查询时判断过期
/**
* 查询用户(逻辑过期缓存策略)
*/
public User getUserById(Long id) {
String cacheKey = USER_CACHE_KEY + id;

// 1. 查询缓存
String cacheJson = redisTemplate.opsForValue().get(cacheKey);
if (cacheJson == null) {
// 缓存未命中:查库并写入缓存(带逻辑过期)
return queryDbAndWriteCache(id, cacheKey);
}

// 2. 缓存命中:解析逻辑过期实体
LogicalExpireCache<User> cacheObj = JSON.parseObject(cacheJson,
new TypeReference<LogicalExpireCache<User>>() {});
User user = cacheObj.getData();
long expireTime = cacheObj.getExpireTime();

// 3. 判断逻辑是否过期
if (System.currentTimeMillis() < expireTime) {
// 3.1 未过期:直接返回数据
return user;
}

// 3.2 已过期:返回旧数据,同时异步更新缓存
cacheUpdatePool.submit(() -> {
// 异步更新缓存(加分布式锁防并发更新,实际业务可省略)
updateCache(id, cacheKey);
});

// 返回旧数据,保证响应速度
return user;
}

/**
* 从数据库查询并写入缓存(带逻辑过期)
*/
private User queryDbAndWriteCache(Long id, String cacheKey) {
User user = userMapper.selectById(id);
if (user != null) {
// 计算逻辑过期时间(当前时间 + 30分钟)
long expireTime = System.currentTimeMillis() + LOGICAL_EXPIRE;
LogicalExpireCache<User> cacheObj = new LogicalExpireCache<>(user, expireTime);
// 写入缓存(Redis层面不设置过期时间)
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cacheObj));
}
return user;
}

秒杀(乐观锁和分布式锁)

优惠券秒杀活动优化:使用乐观锁和 Redission 分布式锁解决了秒杀活动中的商品超卖现象和一人一单的限制。使
用 Redis+RabbitMQ 对项目进行优化,系统吞吐量提升 50%

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
1. 乐观锁
// 乐观锁扣减库存SQL
@Update("UPDATE coupon SET stock = stock - 1, version = version + 1 " +
"WHERE id = #{couponId} AND stock > 0 AND version = #{version}")
int decreaseStockWithOptimisticLock(
@Param("couponId") Long couponId,
@Param("version") Integer version);

2. 分布式锁(用于防止一个用户多次下单)

String lockKey = SECKILL_LOCK_KEY + couponId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 获取分布式锁
boolean isLocked = lock.tryLock(5, 30, TimeUnit.SECONDS);
if (!isLocked) {
throw new BusinessException("购买失败");
}
//其他业务操作
...
}

3. 结合Redis和RabbitMQ处理下单解耦和防止一个人多个订单
1). 若要使用RabbitMQ来解耦下单的过程。分布式锁就只隔离了消息发送这一阶段,若RabbitMQ卡住,可能会导致在判断当前用户是否已下单的时候出现错误导致出现一个人多个订单的情况。
2). 使用Redis的SET NX 来标记用户是否已经下单。
// 2. 用Redis设置用户下单标记(SET NX原子操作,过期时间10分钟)
String userFlagKey = USER_ORDER_FLAG_KEY + couponId + ":" + userId;
Boolean isFirst = redisTemplate.opsForValue().setIfAbsent(userFlagKey, "1", 10, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(isFirst)) {
// 标记已存在,说明已发送过消息
throw new BusinessException("购买失败");
}

...//乐观锁,创建订单,消息发送等。。。

4. 优化:setNX + UserId作为分布式锁直接一步到位
SET NX(用户粒度锁)更适合:
秒杀场景中 “一人一单” 的严格限制(锁粒度天然适配)。
业务逻辑简单、执行时间短且可控(无需复杂续期)。
追求极致性能,能接受手动处理锁细节。
团队对 Redis 命令熟悉,可规避锁释放、续期等潜在风险。

Redisson 更适合:
业务逻辑复杂、执行时间不确定(需自动续期)。
需高可用性(依赖 Redis 集群)和多种锁类型(如公平锁)。
团队更关注业务逻辑,希望减少分布式锁的底层编码工作。
系统中存在多种分布式并发场景(如秒杀、库存更新、分布式任务调度等),需统一锁框架。

为什么用websocket而不用接口

1
2
3
4
5
6
7
8
9
10
传统接口:
1. 无法主动推送数据
2. 连接开销大
3. 实时性差

WebSocket:
1. 全双工实时通信:服务端可主动推数据
2. 连接开销极低:一次握手,长期复用
3. 低延迟:数据传输无 “请求等待”
4. 服务端资源占用更优:减少无效连接

合适的字段建立索引和使用覆盖索引优化 SQL 查询性能,查询耗时减少 80%

1
2
3
4
5
6
7
8
1. 执行计划:EXPLAIN显示type = ALL(全表扫描),rows约 500 万(需扫描全表),Extra显示 “Using where; Using filesort”(需在内存 / 磁盘中排序);
2. 对同一用户(user_id=12345,该用户有 100 条符合条件的订单)执行 10 次查询,取平均耗时为1500ms(1.5 秒),其中大部分时间用于全表扫描和文件排序;

1. 索引设计:针对查询条件user_id和create_time,建立联合索引idx_user_create(user_id, create_time),且包含查询字段id, pay_amount(InnoDB 的二级索引会包含主键id,因此实际索引字段为(user_id, create_time, id, pay_amount));
2. 覆盖索引效果:查询所需的id, create_time, pay_amount均可从索引中获取,无需回表查询主键索引(Extra显示 “Using index”),且ORDER BY create_time可利用索引的有序性,避免文件排序。

1. 耗时统计:同样对user_id=12345执行 10 次查询,平均耗时降至300ms;
2. 性能提升计算:耗时从 1500ms 减少到 300ms,减少了 1200ms,计算得:(1500-300)/1500 = 80%,即查询耗时减少 80%。”