简单的Redis分布式锁实现

业务模型

这里以秒杀系统为例,在短时间内会发生大量的并发访问。我们需要精确的控制数据的存储与修改。

Service层

这里实现两个简单的功能,一个是查询库存,一个是扣除库存。 因为是模拟业务情景,所以可以暂时不用设计到数据库的访问。

@Service
public class SecKillServiceImpl {
    // 模拟数据库中的数据。 产品, 库存, 订单
    static Map<String, Integer> products;
    static Map<String, Integer> stock;
    static Map<String, String> orders;

    static {
        products = new HashMap<>();
        stock = new HashMap<>();
        orders = new HashMap<>();
        products.put("123456", 100000);
        stock.put("123456", 100000);
    }

    /**
     * 根据商品id获取
     * @param productId 商品id
     * @return  商品所剩库存和成功下单的用户
     */
    public String queryMap(String productId) {
        return "限量:" + products.get(productId) + "份,  还剩:" + stock.get(productId) + "份, 成功下单用户:" + orders.size();
    }

    /**
     *  下单操作
     * @param productId 要购买的商品id
     */
    public void orderProductMockDiffUser(String productId) {
        // 查看对应的商品是否还有库存
        Integer stockNum = stock.get(productId);
        if(stockNum == 0) {
            throw new SellException(100, "活动结束");
        } else {
            // 生成随机id,当作购买用户
            orders.put(KeyUtil.genUniqueKey(), productId);            
            stockNum = stockNum - 1;
            // 模拟一些IO操作
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 库存减少
            stock.put(productId, stockNum);
        }
    }
}

以上就是Service层的代码。 主要模拟了两个操作,一个是根据商品id查看剩余数量和下单用户数,一个是下单操作。

Controller层

@RestController
@Slf4j
@RequestMapping("/skill")
public class SecKillController {

    @Autowired
    SecKillServiceImpl secKillService;

    @GetMapping("/query/{productId}")
    public String query(@PathVariable("productId") String productId) {
        return secKillService.queryMap(productId);
    }

    @GetMapping("/order/{productId}")
    public String skill(@PathVariable("productId") String productId) {
        log.info("secKill request, productId:" + productId);
        secKillService.orderProductMockDiffUser(productId);
        return secKillService.queryMap(productId);
    }
}

Controller层比较简单,就是两个接口,对应Service层的两个方法。

测试

先调用几次下单接口,然后再查询一下剩余数量。看看业务代码的表现情况。
这里先用手动调用的方法,多调用几次下单接口,再看。
剩余数量

这里可以看出来,剩余数量和成功下单用户之和等于总数。那么可以说明代码是没有问题的

接下来,我们模拟一下高并发的情况。在此使用apache ab来进行压测。
这里就先简单的介绍一下apache ab的使用:

ab -n 1000 -c 100 http://localhost:8080/sell/skill/order/123456

-n 后面的参数是表示 发送1000个请求
-c 后面的参数是表示 模拟100个并发
后面的url就是要测试访问的地址了

ab -t 60 -c 100 http://localhost:8080/sell/skill/order/123456

当然还有如上的另外一种测试, -t 表示60s内发送请求

这里我们使用第一种方式来进行测试。测试完之后,再调用query接口查看
剩余数量

可以发现,这里的数量就不合适了。
至于为什么会产生这样的情况,是因为这里的业务操作不是线程安全的。 当A线程正在修改库存剩余数量时(此时还未修改完成), 但是B线程又来读取当前剩余库存了。这时两者读取到的剩余库存是一样的,所以当订单orders的size增加了,但是库存量只减少了一个。

synchronized处理并发

对于多线程并发有过了解的同学可能会想到,可以用synchronized来处理刚才所出现的情况。 那么真的可以很好的解决吗? 我们再来测试一下
我们只需要在Service层下单的方法上加上synchronized就可以了

public synchronized void orderProductMockDiffUser(String productId) {
    // 代码省略
}

那么这时,我们再用apache ab进行一下压测。
剩余数量

可以发现,虽然结果能够正确,但是非常耗时。 如果我们只是对一个商品进行秒杀活动,那么这样做是没有什么问题的。但是如果当有多个商品都要做秒杀活动,A商品的购买量很大,但是B商品的购买量相对小很多。但是在秒杀A的时候就已经阻塞了下单的方法,那么也会导致B商品的下单方法阻塞。

Redis分布式锁

Redis之所以可以作为分布式锁,是因为它是单线程的。
我们在进行库存,订单操作之前,先添加一个锁,当操作完之后再解锁。在被锁上的情况下,又想有库存,订单操作那么就直接返回。

加锁

这里先介绍两个redis的命令:
SETNX
将key设置值为value,如果key不存在,这种情况下等同SET命令。 当key存在时,什么也不做。SETNX是”SET if Not eXists”的简写。

// 对应的java方法
boolean result = redisTemplate.opsForValue().setIfAbsent(key, value);

GETSET
自动将key对应到value并且返回原来key对应的value。如果key存在但是对应的value不是字符串,就返回错误。

// 对应的java方法
String value = redisTemplate.opsForValue().getAndSet(key, value);

Redis加锁的基本思路:
1. 根据商品id为key,时间戳(当前时间+超时时间)为value。调用SETNX设置redis
2. 如果设置成功则直接返回true,表示加锁成功。 如果返回false,则继续向下判断
3. 如果直接设置value失败,还需要进一步判断锁是否过期。如果未过期则直接返回false,表示加锁失败。如果过期继续向下判断
4. 如果锁过期,那么调用GETSET方法,获取旧的value值,并将当前的value设置进去。
5. 获取到旧的value值之后,再判断一下旧的value是否等于当前value值。如果等于则返回true。 否则返回false

/**
 * 加锁
 * @param key
 * @param value 当前时间+超时时间
 * @return 返回true表示加锁成功
 */
public boolean lock(String key, String value) {
    // 1.如果不存在key,则存入redis。并返回true
    // 如果存在该key,则返回false
    if(redisTemplate.opsForValue().setIfAbsent(key, value)) {
        return true;
    }
    String currentValue = redisTemplate.opsForValue().get(key);
    // 3.如果锁过期
    if(!StringUtils.isNotBlank(currentValue)
            && Long.valueOf(currentValue) < System.currentTimeMillis()) {
        // 4.获取上一个锁的时间
        // 将value设置到key, 并返回原来的value值
        String oldValue = redisTemplate.opsForValue().getAndSet(key, value);
        // 5.此处的判断处于多线程
        if(!StringUtils.isNotBlank(oldValue) && oldValue.equals(currentValue)) {
            return true;
        }
    }

    return false;
}

这里重点说一下流程5。 可能有的人会疑问,为什么锁都过期了,还要再进行一次判断呢?
我们先想象一下这样的场景: 先有一个A线程来访问了,它来加了锁,并且设置了一个value值A。这时有两个线程B,C来了。它们先判断是否加锁了,

// 这个时候获取到的currentValue值就是A
String currentValue = redisTemplate.opsForValue().get(key);

我们假设现在锁已经过期了,此时来到了

String oldValue = redisTemplate.opsForValue().getAndSet(key, value);

这里虽然是B,C两个线程,但是这个方法的调用肯定是有一个先后顺序的,因为Redis是单线程的。
如果B线程先调用,那么oldValue还是A,这时C线程再调用,oldValue就不再是A了。
所以这时再去判断一下oldValue是否等于currentValue。如果等于就说明加锁成功了,不等于说明属于线程C那样,已经被B给加锁了。

解锁

解锁就很好理解了,就是将Redis中的key给清空

public void unlock(String key, String value) {
    String currentValue = redisTemplate.opsForValue().get(key);
    if(StringUtils.isNotBlank(currentValue)
            && currentValue.equals(value)) {
        redisTemplate.opsForValue().getOperations().delete(key);
    }
}

测试

所以加锁后的代码如下

public void orderProductMockDiffUser(String productId) {
    // 加锁
    Long value = System.currentTimeMillis() + OVERTIME;
    boolean lock = redisLock.lock(productId, String.valueOf(value));
    if(!lock) {
        throw new SellException(100, "哎哟喂,抢购的人太多了,请重试..");
    }

    Integer stockNum = stock.get(productId);
    if(stockNum == 0) {
        throw new SellException(100, "活动结束");
    } else {
        orders.put(KeyUtil.genUniqueKey(), productId);
        stockNum = stockNum - 1;
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        stock.put(productId, stockNum);
    }
    // 解锁
    redisLock.unlock(productId, String.valueOf(value));
}

结果如下:
Redis锁后的结果

可以看到,这样结果就是对的了。因为锁的时间是随便设置的,所以成功下单的用户比较少

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值