重复消费、重复下单的问题,主要的解决办法就是做好幂等,因为在分布式系统中,我们是没办法保证消息不会重新投递的,也没办法保证用户一定不会快速的点击两次进行下单。
所以,对于服务的提供者来说,我们需要在接口中做好幂等控制,来避免因为重复而导致的脏数据。
对于消息的重复消费问题,比较常见的解决方式,就是通过消息中定义的一个幂等号,来做防重判断。这个幂等号一般是约定好的一个业务字段,如果没有这样一个字段的话,也可以用消息中间件的msgid来做幂等控制,但是可能存在一个情况,那就是发送者重复发送了多次消息,这就会导致几次消息的msgid不一样,但是消息内容一致。所以,一般都需要在消息中约定一个唯一的幂等字段或者业务字段。
而对于重复下单的场景,这个幂等号应该怎么产生呢?有一个好的办法就是生成token,当用户每一次访问页面的时候,都向后端接口请求获取一个token,然后在之后本页面的操作中,都需要把这个token带过来。如果页面没有刷新,这个token应该是不变的。
有了这个token就可以用它来做防重控制,并且还能避免有人恶意的刷我们的接口。
在消息或者下单场景中,有了唯一的幂等字段之后,就可以基于一锁、二判、三更新来进行幂等控制了,详见:
使用Redis可以很方便地实现token的验证,并且可以让一个token只能用一次,具体的实现方式如下:
import redis.clients.jedis.Jedis;
import java.util.UUID;
public class RedisToken {
public static void main(String[] args) {
Jedis jedis = new Jedis("localhost");
String token = UUID.randomUUID().toString();
jedis.setex(token, 60 * 60, "1");
System.out.println("Token: " + token);
jedis.close();
}
}
这里只展示了用UUID的方式,而在分布式场景中,如果要生成一个全局唯一的ID,有很多其他方案,这里就不展开介绍了,详见:
在生成了token之后,后续请求就可以校验这个token是否有效,并且确保只能用一次:
import redis.clients.jedis.Jedis;
public class redisTokenVerify {
public void verify(String token) {
Jedis jedis = new Jedis("localhost");
String value = jedis.get(token);
if (value != null) {
//删除token
jedis.del(token);
//校验成功
} else {
//校验失败
}
jedis.close();
}
}
这是一个token校验的简单实现,但是这个逻辑存在一个问题,那就是如果高并发场景,可能会导致多个线程同时,导致token同时校验通过。要解决这个问题,有两个办法,第一个是把get和del放到一个事务中,或者用lua脚本,或者用分布式锁也可以。
如使用Redis的事务:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
public class RedisTokenVerifyWithTransaction {
public void verify(String token) {
Jedis jedis = new Jedis("localhost");
Transaction tx = jedis.multi();
tx.get(token);
tx.del(token);
Object result = tx.exec().get(0);
if (result != null) {
//校验成功
} else {
//校验失败
}
jedis.close();
}
}
或者直接用 redis 的 del,他会返回被删除的 key 的数量,当返回1的时候表示存在并删除了1条。
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
public class RedisTokenVerifyWithTransaction {
public void verify(String token) {
Jedis jedis = new Jedis("localhost");
Transaction tx = jedis.multi();
if (tx.del(token) == 1) {
//校验成功
} else {
//校验失败
}
jedis.close();
}
}