1. 幂等性简介
1.1 什么是幂等性?
幂等性(Idempotence)是一个数学概念,在计算机科学领域,它被用来描述一类特殊的操作:对同一个系统,使用同样的参数,不管执行多少次,都应该产生同样的结果。也就是说,一个幂等操作的效果和执行次数无关。
举个简单的例子,假设我们有一个接口用于创建用户。如果这个接口是幂等的,那么无论调用多少次,只会创建一个用户。相反,如果这个接口不是幂等的,多次调用可能会创建多个重复的用户,导致数据不一致。
1.2 幂等性的重要性
在分布式系统中,由于存在各种不确定因素,我们很难保证每个请求都只被处理一次。网络可能会延迟或中断,导致请求超时;为了提高可用性,我们可能会设置重试机制,导致请求被多次发送;多个线程或进程可能同时访问同一资源,导致竞态条件…在这种情况下,如果我们的操作不是幂等的,就可能导致数据错误、状态不一致等严重问题。
因此,幂等性是构建健壮的分布式系统的一个关键要求。通过合理的幂等性设计,我们可以大大提高系统的容错性和稳定性。
1.3 幂等性在分布式系统中的应用
在分布式系统中,幂等性几乎无处不在。任何可能被多次调用的操作,都应该考虑幂等性设计。下面是一些常见的应用场景:
-
创建、更新、删除等写操作:这些操作通常要修改数据,如果不是幂等的,可能导致数据重复或错误。
-
消息队列的消费:如果消息被重复消费,而消费者的处理逻辑不是幂等的,可能导致数据不一致。
-
支付、下单等关键业务:这些操作通常涉及资金交易或库存变更,如果不是幂等的,可能导致严重的财务或业务问题。
-
REST API:根据HTTP规范,GET、PUT、DELETE等方法应该是幂等的,而POST方法不是。
2. 实现幂等性的常见方案
2.1 唯一索引
2.1.1 唯一索引的原理
唯一索引是指在数据库表的某个字段或字段组合上添加一个唯一性约束 ,确保在该字段或字段组合上不会出现重复的值。当我们尝试插入一个在唯一索引上重复的记录时,数据库会拒绝该操作并返回错误。
2.1.2 唯一索引的使用场景
唯一索引适用于那些天然具有唯一性的业务字段,例如:
- 订单号:每个订单的订单号都是唯一的。
- 用户名:在同一个系统中,每个用户的用户名都是唯一的。
- 外部交易号:如果我们的系统需要与外部系统交互,并使用外部系统的交易号作为唯一标识,就可以在这个字段上添加唯一索引。
通过在这些字段上添加唯一索引,我们可以防止重复记录的插入,从而实现操作的幂等性。
2.1.3 唯一索引的局限性
虽然唯一索引是一种简单有效的幂等性实现方案,但它也有一些局限性:
- 只适用于插入操作,对于更新、删除等操作无效。
- 只能防止完全相同的记录,如果记录中的其他字段不同,仍然可以插入。
- 在分布式环境下,唯一索引无法防止多个节点同时插入相同的记录。
2.2 乐观锁
2.2.1 乐观锁的原理
乐观锁(Optimistic Lock)是一种并发控制策略。 它假设多个事务可以频繁地完成,而不会互相影响,因此在执行更新操作时,它不会加锁,而是在事务提交时,通过某种机制来检测是否有其他事务对数据进行了修改。如果检测到冲突,当前事务就会回滚并重试。
乐观锁通常使用版本号(Version)或时间戳(Timestamp)来实现。 每个记录都有一个版本号字段,每次更新时,版本号会自动加1。当提交事务时,我们会检查当前版本号是否与读取数据时的版本号一致。如果一致,说明没有冲突,可以提交;如果不一致,说明有其他事务修改了数据,当前事务需要回滚。
2.2.2 乐观锁的实现方式
使用版本号实现乐观锁的Java代码示例:
public void updateUser(User user) {
User oldUser = userMapper.selectById(user.getId());
if (oldUser.getVersion() != user.getVersion()) {
throw new OptimisticLockException("User has been modified");
}
user.setVersion(oldUser.getVersion() + 1);
userMapper.updateById(user);
}
在这个例子中,我们首先查询出数据库中的旧记录,然后比较旧记录的版本号与当前记录的版本号。如果不一致,说明有其他事务修改了数据,我们就抛出一个乐观锁异常。如果一致,我们就将版本号加1,然后更新记录。
2.2.3 乐观锁的使用场景
乐观锁适用于并发冲突不太严重、事务执行时间较短的场景。在这种情况下,乐观锁可以避免频繁的加锁操作,提高系统的并发性能。
一些典型的使用场景包括:
- 商品库存的扣减。
- 用户账户余额的更新。
- 文档的协同编辑。
然而,如果冲突非常频繁,乐观锁可能会导致大量的事务回滚和重试,反而影响系统的性能。在这种情况下,我们可能需要考虑使用悲观锁。
2.3 悲观锁
2.3.1 悲观锁的原理
悲观锁(Pessimistic Lock)与乐观锁相反, 它总是假设最坏的情况,认为多个事务之间一定会发生冲突。 因此,在执行更新操作时,它会先对数据加锁,防止其他事务的访问,直到当前事务提交或回滚。
悲观锁通常使用数据库的锁机制来实现,例如在SQL语句中添加FOR UPDATE
子句。当一个事务获取了悲观锁,其他事务就必须等待,直到该锁被释放。
2.3.2 悲观锁的实现方式
使用MySQL的SELECT ... FOR UPDATE
语句实现悲观锁的Java代码示例:
public void updateUser(User user) {
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM user WHERE id = ? FOR UPDATE")) {
stmt.setLong(1, user.getId());
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
User oldUser = new User();
oldUser.setId(rs.getLong("id"));
oldUser.setName(rs.getString("name"));
// 更新用户信息...
try (PreparedStatement updateStmt = conn.prepareStatement(
"UPDATE user SET name = ? WHERE id = ?")) {
updateStmt.setString(1, user.getName());
updateStmt.setLong(2, user.getId());
updateStmt.executeUpdate();
}
}
}
}
conn.commit();
} catch (SQLException e) {
// 处理异常...
}
}
在这个例子中,我们使用SELECT ... FOR UPDATE
语句查询用户记录,并对该记录加锁。这样,其他事务如果也想更新这个用户,就必须等待当前事务释放锁。在锁定记录后,我们可以安全地更新用户信息,然后提交事务,释放锁。
2.3.3 悲观锁的使用场景
悲观锁适用于并发冲突很严重、事务执行时间较长的场景。 在这种情况下,使用悲观锁可以避免大量的事务回滚和重试,提高系统的稳定性。
一些典型的使用场景包括:
- 资源预留,如车票、酒店预订等。
- 重要数据的更新,如用户余额、积分等。
- 多个步骤组成的复杂事务。
然而, 悲观锁会降低系统的并发性能, 因为它限制了多个事务的并行执行。因此,在使用悲观锁时,我们需要仔细评估是否真的需要这么强的一致性保证。
3. 高并发场景下的幂等性设计
在 高并发的分布式系统中,仅仅依靠数据库的唯一索引或单体应用中的锁机制,往往无法完全保证操作的幂等性。 这是因为在分布式环境下,多个节点可能同时处理相同的请求,导致竞态条件的出现。为了解决这个问题,我们需要引入分布式锁。
3.1 高并发下的数据不一致问题
3.1.1 多线程并发访问的问题
在 单体应用 中,我们可以使用语言级别的锁(如Java的synchronized
关键字)来保证多个线程对共享资源的互斥访问。但在分布式系统中,请求可能被分发到不同的节点上处理,这些节点之间无法直接共享锁。
举个例子,假设我们有一个用于创建用户的API。如果两个请求同时到达不同的节点,这两个节点都会执行以下步骤:
- 检查用户名是否已存在。
- 如果不存在,创建新用户。
如果我们仅仅在数据库层面使用唯一索引来保证用户名的唯一性,可能会出现以下情况:
- 节点A检查用户名,发现不存在。
- 节点B检查用户名,也发现不存在。
- 节点A创建新用户。
- 节点B创建新用户。
最终,我们的系统中就会出现两个相同用户名的用户,导致数据不一致。
3.1.2 网络延迟和请求重试的影响
在分布式系统中,网络是不可靠的。请求可能会因为网络延迟或暂时的节点不可用而失败。为了提高系统的可用性,我们通常会在客户端或网关上添加重试机制。
然而,如果我们的操作不是幂等的,重试可能会导致数据的不一致。例如,如果一个用于创建订单的请求第一次执行成功,但是因为网络问题没有及时返回响应,客户端可能会发起第二次请求。如果我们的创建订单操作不是幂等的,就会导致重复的订单被创建。
3.2 分布式锁
为了解决高并发下的数据不一致问题,我们需要一种机制来保证在分布式环境下,同一时刻只有一个节点可以处理某个特定的请求。这就是分布式锁的作用。
3.2.1 什么是分布式锁?
分布式锁是一种在分布式环境下用于协调多个节点访问共享资源的机制。它可以保证在同一时刻,只有一个节点可以持有锁并访问共享资源,其他节点必须等待,直到锁被释放。
一个有效的分布式锁应该具备以下特性:
- 互斥性:在同一时刻,只能有一个节点持有锁。
- 高可用性:锁服务本身必须是高可用的,否则会成为系统的单点故障。
- 高性能:锁的获取和释放必须快速,否则会成为系统的性能瓶颈。
- 可重入性:同一个节点多次获取同一个锁,不会产生死锁。
3.2.2 分布式锁的实现方案
常见的分布式锁实现方案包括:
-
基于数据库的实现:使用数据库的唯一性约束(如唯一索引)来实现锁。例如,我们可以在数据库中创建一个特殊的"锁表",每次需要获取锁时,就向这个表中插入一条记录。由于唯一性约束的存在,同一时刻只有一个节点可以成功插入记录,从而获得锁。
-
基于缓存的实现:使用Redis等分布式缓存来实现锁。例如,我们可以使用Redis的
SETNX
命令来尝试获取锁。如果SETNX
返回1,说明锁获取成功;如果返回0,说明锁已经被其他节点持有。 -
基于Zookeeper的实现:使用Zookeeper的临时顺序节点来实现锁。每个节点尝试创建一个临时顺序节点,如
/locks/lock-0000000001
。由于Zookeeper保证节点的顺序性,编号最小的节点就可以获得锁。其他节点需要监听前一个节点的删除事件,当前一个节点释放锁(删除节点)时,下一个节点就可以获得锁。
3.2.3 使用分布式锁实现幂等性
3.2.3.1 以Redis为例的分布式锁实现
首先定义一个Redis
配置类
package cn.serein.charging.device.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
/**
* Redis配置类
*/
@Configuration
public class RedisConfiguration {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate redisTemplate = new RedisTemplate();
//设置连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
// 设置Redis的序列化器
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(RedisSerializer.json());
// 设置hash的序列化器
redisTemplate.setHashKeySerializer(RedisSerializer.string());
redisTemplate.setHashValueSerializer(RedisSerializer.json());
return redisTemplate;
}
}
再定义一个RedisLock
类,用于封装Redis的锁操作:
@Component
public class RedisLock {
@Autowired
private RedisTemplate redisTemplate;
private static final String LOCK_PREFIX = "lock:";
private static final long LOCK_EXPIRE = 30; // 锁的过期时间,单位为秒
public boolean acquire(String lockKey) {
String key = LOCK_PREFIX + lockKey;
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_EXPIRE, TimeUnit.SECONDS);
return success != null && success;
}
public void release(String lockKey) {
String key = LOCK_PREFIX + lockKey;
redisTemplate.delete(key);
}
}
这个类使用Redis的SETNX
命令和过期时间来实现锁。acquire
方法尝试获取锁,如果成功则返回true
,否则返回false
。release
方法释放锁。
然后,我们可以在业务方法中使用这个RedisLock
来保证操作的幂等性:
@Service
public class UserService {
@Autowired
private RedisLock redisLock;
@Autowired
private UserMapper userMapper;
public void createUser(String username) {
String lockKey = "user:" + username;
try {
if (redisLock.acquire(lockKey)) {
if (userMapper.selectByUsername(username) == null) {
User user = new User();
user.setUsername(username);
userMapper.insert(user);
}
} else {
throw new DuplicateRequestException("Duplicate request for creating user");
}
} finally {
redisLock.release(lockKey);
}
}
}
在这个例子中,我们在创建用户之前,先尝试获取一个以用户名为key的锁。如果获取成功,我们再检查用户是否已经存在,如果不存在就创建新用户。无论操作是否成功,我们都要在最后释放锁。如果获取锁失败,说明有其他请求正在处理,我们就直接抛出重复请求的异常。
通过这种方式,我们可以保证在高并发情况下,同一个用户名只会被创建一次,从而实现操作的幂等性。
3.2.3.2 分布式锁的使用注意事项
在使用分布式锁时,我们需要注意以下几点:
-
锁的粒度要适当,不要过大也不要过小。锁的粒度过大会影响系统的并发性能,过小可能无法完全保证操作的原子性。
-
锁的持有时间不要过长。如果一个节点长时间持有锁而没有释放,会导致其他节点长时间等待,影响系统的可用性。我们可以为锁设置一个合理的过期时间,即使出现异常情况,锁也会在一定时间后自动释放。
-
要正确处理获取锁和释放锁的异常情况。如果获取锁或释放锁的过程中出现异常,我们要能够正确地处理,避免死锁或者锁泄露的情况。
-
在某些情况下,我们可能需要实现锁的重入性,即同一个节点可以多次获取同一个锁。这需要我们在锁的设计上进行一些特殊处理。