当前位置: 代码网 > it编程>数据库>Redis > Redis解决秒杀微服务抢购代金券超卖和同一个用户多次抢购

Redis解决秒杀微服务抢购代金券超卖和同一个用户多次抢购

2025年09月22日 Redis 我要评论
之前的博客,我通过传统的数据库方式实现秒杀按照正常逻辑来走,通过压力测试发现会有超卖合同一用户可以多次抢购同一代金券的问题。本文我将讲述通过redis来解决超卖和同一用户多次抢购问题。超卖和同一用户多

之前的博客,我通过 传统的数据库方式实现秒杀按照正常逻辑来走,通过压力测试发现会有超卖合同一用户可以多次抢购同一代金券的问题。本文我将讲述通过redis来解决超卖和同一用户多次抢购问题。

超卖和同一用户多次抢购问题分析

    /**
     * 抢购代金券
     *
     * @param voucherid   代金券 id
     * @param accesstoken 登录token
     * @para path 访问路径
     */
    public resultinfo doseckill(integer voucherid, string accesstoken, string path) {
        // 基本参数校验
        assertutil.istrue(voucherid == null || voucherid < 0, "请选择需要抢购的代金券");
        assertutil.isnotempty(accesstoken, "请登录");
        // 判断此代金券是否加入抢购
        seckillvouchers seckillvouchers = seckillvouchersmapper.selectvoucher(voucherid);
        assertutil.istrue(seckillvouchers == null, "该代金券并未有抢购活动");
        // 判断是否有效
        assertutil.istrue(seckillvouchers.getisvalid() == 0, "该活动已结束");
        // 判断是否开始、结束
        date now = new date();
        assertutil.istrue(now.before(seckillvouchers.getstarttime()), "该抢购还未开始");
        assertutil.istrue(now.after(seckillvouchers.getendtime()), "该抢购已结束");
        // 判断是否卖完
        assertutil.istrue(seckillvouchers.getamount() < 1, "该券已经卖完了");
        // 获取登录用户信息
        string url = oauthservername + "user/me?access_token={accesstoken}";
        resultinfo resultinfo = resttemplate.getforobject(url, resultinfo.class, accesstoken);
        if (resultinfo.getcode() != apiconstant.success_code) {
            resultinfo.setpath(path);
            return resultinfo;
        }
        // 这里的data是一个linkedhashmap,signindinerinfo
        signindinerinfo dinerinfo = beanutil.fillbeanwithmap((linkedhashmap) resultinfo.getdata(),
                new signindinerinfo(), false);
        // 判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
        voucherorders order = voucherordersmapper.finddinerorder(dinerinfo.getid(),
                seckillvouchers.getid());
        assertutil.istrue(order != null, "该用户已抢到该代金券,无需再抢");
        // 扣库存
        int count = seckillvouchersmapper.stockdecrease(seckillvouchers.getid());
        assertutil.istrue(count == 0, "该券已经卖完了");
        // 下单
        voucherorders voucherorders = new voucherorders();
        voucherorders.setfkdinerid(dinerinfo.getid());
        voucherorders.setfkseckillid(seckillvouchers.getid());
        voucherorders.setfkvoucherid(seckillvouchers.getfkvoucherid());
        string orderno = idutil.getsnowflake(1, 1).nextidstr();
        voucherorders.setorderno(orderno);
        voucherorders.setordertype(1);
        voucherorders.setstatus(0);
        count = voucherordersmapper.save(voucherorders);
        assertutil.istrue(count == 0, "用户抢购失败");

        return resultinfoutil.buildsuccess(path, "抢购成功");
    }

高并发环境下会导致上图的判断出现错误。在高并发环境下,会有多个线程拿到的库存值都大于0,实际的继续往下执行的线程会高于实际的库存值,继续执行会导致卖出的订单量超过库存本身的数量,导致库存超卖。

同理同一用户多次发起,同时到达这一步也会错判,在还没有获取到最新的存储结果时,都会判定成是未抢购过,导致同一用户可以重复抢购问题。

解决库存超卖问题

添加相关枚举

在redis键的枚举类中添加如下枚举:

分布式锁的key来约束同一用户只能抢购一次。

添加redistemplate配置类

/**
 * redistemplate配置类
 * @author zjq
 */
@configuration
public class redistemplateconfiguration {

    /**
     * redistemplate 序列化使用的jdkserializeable, 存储二进制字节码, 所以自定义序列化类
     *
     * @param redisconnectionfactory
     * @return
     */
    @bean
    public redistemplate<object, object> redistemplate(redisconnectionfactory redisconnectionfactory) {
        redistemplate<object, object> redistemplate = new redistemplate<>();
        redistemplate.setconnectionfactory(redisconnectionfactory);

        // 使用jackson2jsonredisserialize 替换默认序列化
        jackson2jsonredisserializer jackson2jsonredisserializer = new jackson2jsonredisserializer(object.class);

        objectmapper objectmapper = new objectmapper();
        objectmapper.setvisibility(propertyaccessor.all, jsonautodetect.visibility.any);
        jackson2jsonredisserializer.setobjectmapper(objectmapper);

        // 设置key和value的序列化规则
        redistemplate.setvalueserializer(jackson2jsonredisserializer);
        redistemplate.setkeyserializer(new stringredisserializer());

        redistemplate.sethashkeyserializer(new stringredisserializer());
        redistemplate.sethashvalueserializer(jackson2jsonredisserializer);

        redistemplate.afterpropertiesset();
        return redistemplate;
    }

    @bean
    public defaultredisscript<long> stockscript() {
        defaultredisscript<long> redisscript = new defaultredisscript<>();
        //放在和application.yml 同层目录下
        redisscript.setlocation(new classpathresource("stock.lua"));
        redisscript.setresulttype(long.class);
        return redisscript;
    }

}

改造原先添加代金券逻辑

原先添加代金券的逻辑如下:

现在需要把跟数据库交互的部分改成和redis交互,改造后代码如下:

    	// 采用 redis 实现
        string key = rediskeyconstant.seckill_vouchers.getkey() +
                seckillvouchers.getfkvoucherid();
        // 验证 redis 中是否已经存在该券的秒杀活动
        map<string, object> map = redistemplate.opsforhash().entries(key);
        assertutil.istrue(!map.isempty() && (int) map.get("amount") > 0, "该券已经拥有了抢购活动");

        // 插入 redis
        seckillvouchers.setisvalid(1);
        seckillvouchers.setcreatedate(now);
        seckillvouchers.setupdatedate(now);
        redistemplate.opsforhash().putall(key, beanutil.beantomap(seckillvouchers));

执行测试,新建秒杀代金券活动存储到redis中:

可以看到数据已经存储到redis中。

改造下单逻辑

调整数据库相关为redis

原先关系型数据库下单逻辑:

    /**
     * 抢购代金券
     *
     * @param voucherid   代金券 id
     * @param accesstoken 登录token
     * @para path 访问路径
     */
    public resultinfo doseckill(integer voucherid, string accesstoken, string path) {
        // 基本参数校验
        assertutil.istrue(voucherid == null || voucherid < 0, "请选择需要抢购的代金券");
        assertutil.isnotempty(accesstoken, "请登录");
        // 判断此代金券是否加入抢购
        seckillvouchers seckillvouchers = seckillvouchersmapper.selectvoucher(voucherid);
        assertutil.istrue(seckillvouchers == null, "该代金券并未有抢购活动");
        // 判断是否有效
        assertutil.istrue(seckillvouchers.getisvalid() == 0, "该活动已结束");
        // 判断是否开始、结束
        date now = new date();
        assertutil.istrue(now.before(seckillvouchers.getstarttime()), "该抢购还未开始");
        assertutil.istrue(now.after(seckillvouchers.getendtime()), "该抢购已结束");
        // 判断是否卖完
        assertutil.istrue(seckillvouchers.getamount() < 1, "该券已经卖完了");
        // 获取登录用户信息
        string url = oauthservername + "user/me?access_token={accesstoken}";
        resultinfo resultinfo = resttemplate.getforobject(url, resultinfo.class, accesstoken);
        if (resultinfo.getcode() != apiconstant.success_code) {
            resultinfo.setpath(path);
            return resultinfo;
        }
        // 这里的data是一个linkedhashmap,signindinerinfo
        signindinerinfo dinerinfo = beanutil.fillbeanwithmap((linkedhashmap) resultinfo.getdata(),
                new signindinerinfo(), false);
        // 判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
        voucherorders order = voucherordersmapper.finddinerorder(dinerinfo.getid(),
                seckillvouchers.getid());
        assertutil.istrue(order != null, "该用户已抢到该代金券,无需再抢");
        // 扣库存
        int count = seckillvouchersmapper.stockdecrease(seckillvouchers.getid());
        assertutil.istrue(count == 0, "该券已经卖完了");
        // 下单
        voucherorders voucherorders = new voucherorders();
        voucherorders.setfkdinerid(dinerinfo.getid());
        voucherorders.setfkseckillid(seckillvouchers.getid());
        voucherorders.setfkvoucherid(seckillvouchers.getfkvoucherid());
        string orderno = idutil.getsnowflake(1, 1).nextidstr();
        voucherorders.setorderno(orderno);
        voucherorders.setordertype(1);
        voucherorders.setstatus(0);
        count = voucherordersmapper.save(voucherorders);
        assertutil.istrue(count == 0, "用户抢购失败");

        return resultinfoutil.buildsuccess(path, "抢购成功");
    }

查询、扣库存和下单逻辑调整成redis:

    	// 扣库存 redis没有自减方法,数值传负数表示自减
        long count = redistemplate.opsforhash().increment(key, "amount", -1);
        assertutil.istrue(count <= 0, "该券已经卖完了");

订单信息还是保存到数据库中

        // 下单
        voucherorders voucherorders = new voucherorders();
        voucherorders.setfkdinerid(dinerinfo.getid());
        //redis中不需要维护该外键信息
//        voucherorders.setfkseckillid(seckillvouchers.getid());
        voucherorders.setfkvoucherid(seckillvouchers.getfkvoucherid());
        string orderno = idutil.getsnowflake(1, 1).nextidstr();
        voucherorders.setorderno(orderno);
        voucherorders.setordertype(1);
        voucherorders.setstatus(0);
        count = voucherordersmapper.save(voucherorders);

jmeter发起3000个线程,2000个用户并发请求,查看库存情况,目前还是超卖的:

订单的数量是正确的:

因为这一步判定是单线程的

long count = redistemplate.opsforhash().increment(key, "amount", -1);
assertutil.istrue(count <= 0, "该券已经卖完了");

是不是先下单然后再扣库存就可以了?当然不行,如果上面位置调整下会导致库存数量不对,订单数量也不对😰😰😰。
我们继续在先下单后扣库存的方法上添加一个事务:@transactional(rollbackfor = exception.class)。
执行发现订单数量正常了,库存还是负数:

为什么会有这个问题呢,因为redistemplate.opsforhash().increment(key, "amount", -1)这一步操作在redis中实际执行的是先查询再减少的操作,在高并发场景下会有问题。我们需要保证这两步的原子性。

redis + lua 解决超卖问题

在yml配置文件同级目录添加lua脚本,脚本内容如下:

if (redis.call('hexists', keys[1], keys[2]) == 1) then
	local stock = tonumber(redis.call('hget', keys[1], keys[2]));
	if (stock > 0) then
	   redis.call('hincrby', keys[1], keys[2], -1);
	   return stock;
	end;
    return 0;
end;

在redistemplate配置类中添加如下配置bean并注入lua脚本:

    @bean
    public defaultredisscript<long> stockscript() {
        defaultredisscript<long> redisscript = new defaultredisscript<>();
        //放在和application.yml 同层目录下
        redisscript.setlocation(new classpathresource("stock.lua"));
        redisscript.setresulttype(long.class);
        return redisscript;
    }

扣库存逻辑调整如下:

            	// 采用 redis + lua 解决超卖问题
                // 扣库存
                list<string> keys = new arraylist<>();
                keys.add(key);
                keys.add("amount");
                long amount = (long) redistemplate.execute(defaultredisscript, keys);
                assertutil.istrue(amount == null || amount < 1, "该券已经卖完了");

重置数据后执行jmeter执行5000个线程,两千个用户并发下单测试,结果如下:

库存0,订单100,超卖问题解决。

jmeter的结果值有中文乱码,进入jmeter安装位置,调整jmeter.properties文件中的sampleresult.default.encoding为utf-8。重启jmeter再测试不再乱码。

解决同一用户多次抢购问题

问题描述

用jmeter测试同一用户并发抢购:

查看数据库发现同一用户下单了多次:

redisson 分布式锁解决同一用户多次下单

什么是redisson

上图就是redission官方网站首页。
首页可以看出来,redisson可以实现很多东西,在redis的基础上,redisson做了超多的封装,我们看一下,例如说
spring cache,tomcatsession,spring session,可排序的set,还有呢sortedsort,下面还有各种队列,包括这种双端
队列,还有map,这些是数据结构,下面就是各种锁,读写锁,这里面的锁还包含,可重入锁,还有countdownlantch,这个是在多线程的时候使用的,比如说我启动很多个线程,去执行某个任务,然后把任务进行切分,都完成之后有一个等待,等待所有线程都达到这里之后,在一起往下走,把异步再变成同步,下边是一些线程池,还有订阅的各种功能,scheduleservice来做调度的一个任务,所以redisson是非常强大的,然后我们在右上角有一个documentation,我们可以打开它,redisson官方也提供了中文文档:https://github.com/redisson/redisson/wiki/目录

问题解决

同一用户可以多次抢购本质上是一个用户在抢购的某个商品的时候没有加锁,导致同一用户的多个线程同时进入抢购,接下来通过redisson分布式锁来解决同一用户多次下单的问题。
锁的对象为用户id和代金券活动id,表示同一用户只能抢购一次某活动。改造后代码如下:

    /**
     * 抢购代金券
     *
     * @param voucherid   代金券 id
     * @param accesstoken 登录token
     * @para path 访问路径
     */
    @transactional(rollbackfor = exception.class)
    public resultinfo doseckill(integer voucherid, string accesstoken, string path) {
        // 基本参数校验
        assertutil.istrue(voucherid == null || voucherid < 0, "请选择需要抢购的代金券");
        assertutil.isnotempty(accesstoken, "请登录");

        // 采用 redis
        string key = rediskeyconstant.seckill_vouchers.getkey() + voucherid;
        map<string, object> map = redistemplate.opsforhash().entries(key);
        seckillvouchers seckillvouchers = beanutil.maptobean(map, seckillvouchers.class, true, null);
        // 判断是否开始、结束
        date now = new date();
        assertutil.istrue(now.before(seckillvouchers.getstarttime()), "该抢购还未开始");
        assertutil.istrue(now.after(seckillvouchers.getendtime()), "该抢购已结束");
        // 判断是否卖完
        assertutil.istrue(seckillvouchers.getamount() < 1, "该券已经卖完了");
        // 获取登录用户信息
        string url = oauthservername + "user/me?access_token={accesstoken}";
        resultinfo resultinfo = resttemplate.getforobject(url, resultinfo.class, accesstoken);
        if (resultinfo.getcode() != apiconstant.success_code) {
            resultinfo.setpath(path);
            return resultinfo;
        }
        // 这里的data是一个linkedhashmap,signindinerinfo
        signindinerinfo dinerinfo = beanutil.fillbeanwithmap((linkedhashmap) resultinfo.getdata(),
                new signindinerinfo(), false);
        // 判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
        voucherorders order = voucherordersmapper.finddinerorder(dinerinfo.getid(),
                seckillvouchers.getid());
        assertutil.istrue(order != null, "该用户已抢到该代金券,无需再抢");

        // 使用 redis 锁一个账号只能购买一次
        string lockname = rediskeyconstant.lock_key.getkey()
                + dinerinfo.getid() + ":" + voucherid;
        long expiretime = seckillvouchers.getendtime().gettime() - now.gettime();

        // redisson 分布式锁
        rlock lock = redissonclient.getlock(lockname);
        try {

            // redisson 分布式锁处理
            boolean islocked = lock.trylock(expiretime, timeunit.milliseconds);
            if (islocked) {
                // 下单
                voucherorders voucherorders = new voucherorders();
                voucherorders.setfkdinerid(dinerinfo.getid());
                //redis中不需要维护该外键信息
                //        voucherorders.setfkseckillid(seckillvouchers.getid());
                voucherorders.setfkvoucherid(seckillvouchers.getfkvoucherid());
                string orderno = idutil.getsnowflake(1, 1).nextidstr();
                voucherorders.setorderno(orderno);
                voucherorders.setordertype(1);
                voucherorders.setstatus(0);
                long count = voucherordersmapper.save(voucherorders);
                assertutil.istrue(count == 0, "用户抢购失败");

                // 采用 redis + lua 解决超卖问题
                // 扣库存
                list<string> keys = new arraylist<>();
                keys.add(key);
                keys.add("amount");
                long amount = (long) redistemplate.execute(defaultredisscript, keys);
                assertutil.istrue(amount == null || amount < 1, "该券已经卖完了");
            }
        } catch (exception e) {
            // 手动回滚事务
            transactionaspectsupport.currenttransactionstatus().setrollbackonly();

            // redisson 解锁
            lock.unlock();
            if (e instanceof parameterexception) {
                return resultinfoutil.builderror(0, "该券已经卖完了", path);
            }
        }

        return resultinfoutil.buildsuccess(path, "抢购成功");
    }

jmeter测试验证,同一用户并发请求某一活动,只能下单一次:

库存剩99,订单1条,完美。

到此这篇关于redis解决秒杀微服务抢购代金券超卖和同一个用户多次抢购的文章就介绍到这了,更多相关redis超卖和同一个用户多次抢购内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!

(0)

相关文章:

  • Redis延迟双删的具体使用

    Redis延迟双删的具体使用

    1、何为延时双删延迟双删(delay double delete)是一种在数据更新或删除时为了保证数据一致性而采取的策略。这种策略通常用于解决数据在缓存和数据库... [阅读全文]
  • Redis中的RDB用法原理及说明

    开篇:数据备份的日常比喻想象一下,你正在玩一个电子游戏,游戏进度非常重要。突然,电脑要重启更新,你会怎么做?聪明的玩家都会先保存游戏进度。redis中的rdb(redis data…

    2025年09月29日 数据库
  • redis-sentinel基础概念及部署流程

    redis-sentinel基础概念及部署流程

    一. 引言redis sentinel 是 redis 官方提供的高可用解决方案,主要用于监控 redis 主从集群,在主节点故障时自动完成故障转移,确保服务持... [阅读全文]
  • Redis中的AOF原理及分析

    开篇:从日记本到aof想象一下,你正在写一本日记,记录每天的重要事件。最初你可能只是简单地写下"今天吃了什么"、"见了谁"这样的简短记录。但…

    2025年09月29日 数据库
  • Redis实现高效内存管理的示例代码

    Redis实现高效内存管理的示例代码

    redis 作为一个高性能的内存数据库,内存管理是其核心功能之一。为了高效地利用内存,redis 采用了多种技术和策略,如优化的数据结构、内存分配策略、内存回收... [阅读全文]
  • Redis中的List结构从使用到原理分析

    开篇:redis list就像超市的购物车想象一下,当我们去超市购物时,推着一辆购物车,可以随意往里面添加商品(从头部或尾部放入),也可以按照放入的顺序取出商品(从头部或尾部取出)…

    2025年09月29日 数据库

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com