在苍穹外卖这种高并发的餐饮系统中,菜品的新增和删除操作看似简单,但在实际应用中却可能面临严重的性能瓶颈。尤其是在促销活动期间,大量商家同时操作菜品,很容易出现数据一致性问题,影响用户体验。本文将深入探讨这一问题,并提供具体的代码和配置方案。
问题场景重现:并发导致的库存不一致
假设我们有一个菜品“麻辣小龙虾”,库存为 100 份。在促销期间,多个商家同时修改该菜品的信息(如价格、库存),如果没有合适的并发控制机制,可能会出现以下问题:
- 超卖:多个请求同时读取到剩余库存为 1,然后都执行减库存操作,导致库存变为负数。
- 数据覆盖:一个请求更新了菜品信息,但另一个请求稍后也更新了,导致前一个请求的修改被覆盖。
底层原理深度剖析:分布式锁与乐观锁
要解决上述问题,我们需要引入并发控制机制。常见的并发控制方案包括:
- 悲观锁(Pessimistic Locking):在修改数据之前,先锁定数据,防止其他线程修改。例如,在数据库中使用
SELECT ... FOR UPDATE语句。 - 乐观锁(Optimistic Locking):在更新数据时,检查数据是否被其他线程修改过。常见的实现方式是使用版本号或时间戳。
- 分布式锁:当应用采用分布式架构时,需要使用分布式锁来保证多个服务实例对同一资源的互斥访问。常用的分布式锁方案包括 Redis 分布式锁和 ZooKeeper 分布式锁。
考虑到苍穹外卖的高并发特性,以及对性能的极致追求,我们选择乐观锁结合 Redis 分布式锁的方案。乐观锁避免了长时间的锁等待,Redis 分布式锁则保证了在分布式环境下的数据一致性。
代码/配置解决方案:基于 Redis 分布式锁的菜品修改
以下是一个使用 Redis 分布式锁控制菜品修改的示例代码(Java):
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
public class DishService {
private JedisPool jedisPool; // Redis 连接池
private DishRepository dishRepository; // 菜品数据访问层
public DishService(JedisPool jedisPool, DishRepository dishRepository) {
this.jedisPool = jedisPool;
this.dishRepository = dishRepository;
}
public boolean updateDish(Long dishId, int newStock, int version) {
String lockKey = "dish:" + dishId; // Redis 锁的 key
String lockValue = String.valueOf(System.currentTimeMillis() + 5000); // 锁的过期时间(5 秒)
try (Jedis jedis = jedisPool.getResource()) {
// 尝试获取锁
String result = jedis.set(lockKey, lockValue, "NX", "PX", 5000); // NX: 不存在才设置, PX: 毫秒
if ("OK".equals(result)) {
// 获取锁成功
Dish dish = dishRepository.findById(dishId); // 获取菜品信息
if(dish.getVersion() != version) {
return false; //版本号不一致,更新失败
}
dish.setStock(newStock);
dish.setVersion(version + 1);
dishRepository.update(dish); // 更新菜品信息
return true; // 更新成功
} else {
// 获取锁失败,稍后重试或者返回错误信息
return false;
}
} finally {
// 释放锁(需要原子操作,防止误删)
try (Jedis jedis = jedisPool.getResource()) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script, 1, lockKey, lockValue);
}
}
}
}
代码解释:
jedis.set(lockKey, lockValue, "NX", "PX", 5000):使用 Redis 的SETNX命令尝试获取锁,如果 key 不存在则设置成功,并设置过期时间为 5 秒。dish.getVersion():获取菜品的版本号,用于乐观锁的判断。dishRepository.update(dish):更新菜品信息,同时增加版本号。jedis.eval(script, 1, lockKey, lockValue):使用 Lua 脚本原子性地判断锁是否属于当前线程,并释放锁。
数据库表结构建议:
CREATE TABLE `dish` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(255) NOT NULL COMMENT '菜品名称',
`stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存',
`price` decimal(10,2) NOT NULL COMMENT '价格',
`version` int(11) NOT NULL DEFAULT '0' COMMENT '版本号,用于乐观锁',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品表';
Nginx 配置(可选,用于负载均衡):
如果应用采用多实例部署,可以使用 Nginx 进行负载均衡。以下是一个简单的 Nginx 配置:
upstream dish_service {
server 192.168.1.100:8080;
server 192.168.1.101:8080;
}
server {
listen 80;
server_name api.cangqiong.com;
location /dish/ {
proxy_pass http://dish_service/dish/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
实战避坑经验总结
- 锁的过期时间:设置合理的锁过期时间非常重要。如果过期时间太短,可能导致锁被提前释放,引发并发问题。如果过期时间太长,可能导致死锁。建议设置一个略大于业务处理时间的过期时间,并增加自动续租机制(Watchdog)。
- Redis 集群:在高并发场景下,单节点的 Redis 可能无法承受压力。建议使用 Redis 集群(如 Redis Cluster 或 Redis Sentinel)来提高可用性和性能。
- 数据库索引:在菜品表上建立合适的索引,可以提高查询性能。例如,可以为
dishId和version字段建立联合索引。 - 重试机制:如果获取锁失败,可以采用重试机制,但需要设置合理的重试次数和间隔,避免死循环。
- 熔断降级:当 Redis 服务出现故障时,可以采用熔断降级策略,例如直接返回默认值或者提示用户稍后重试,保证系统的可用性。
- 监控与告警:建立完善的监控和告警机制,及时发现和解决问题。可以监控 Redis 的 CPU 使用率、内存使用率、连接数等指标。
通过以上方案,可以有效地解决苍穹外卖菜品新增、删除操作中的并发问题,提高系统的稳定性和性能。同时,合理的 Nginx 配置能够进一步提升系统的整体负载能力。
冠军资讯
加班到秃头