【Redis】4、全局唯一 ID生成、单机(非分布式)情况下的秒杀和一人一单

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: 【Redis】4、全局唯一 ID生成、单机(非分布式)情况下的秒杀和一人一单


一、利用 Redis 实现全局唯一 ID 生成

(1) 为啥要用全局唯一 ID 生成

CREATE TABLE `tb_voucher_order` (
  `id` bigint(20) NOT NULL COMMENT '主键',
  `user_id` bigint(20) unsigned NOT NULL COMMENT '下单的用户id',
  `voucher_id` bigint(20) unsigned NOT NULL COMMENT '购买的代金券id',
  `pay_type` tinyint(1) unsigned NOT NULL DEFAULT '1' COMMENT '支付方式 1:余额支付;2:支付宝;3:微信',
  `status` tinyint(1) unsigned NOT NULL DEFAULT '1' COMMENT '订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
  `pay_time` timestamp NULL DEFAULT NULL COMMENT '支付时间',
  `use_time` timestamp NULL DEFAULT NULL COMMENT '核销时间',
  `refund_time` timestamp NULL DEFAULT NULL COMMENT '退款时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT

🍀 id 字段不是自增 AUTO_INCREMENT


  • 每个店铺都可以发布优惠券:

  • 用户抢购的时候会生成订单并保存到 tb_voucher_order 这张表中
  • 如订单 id 使用数据库自增 ID 会出现以下问题:

🍀 id 规律性太明显(可能会被用户猜测到优惠券的 id)

🍀 受单表数据量的限制(优惠券订单可能很多,当分库分表的时候,每张表的 id 各自递增)

(2) 全局唯一 ID 生成器

🍀 全局 ID 生成器:一种在分布式系统下用来生成全局唯一 ID 的工具。一般要满足下列特性:

🍀 ① 唯一性:一个 ID 只能对应数据库中的一条记录

🍀 ② 高可用:生成 ID 的功能在高并发情况下也要能够提供服务

🍀 ③ 高性能:生成 ID 的速度要足够快(否则会影响其他业务的功能)

🍀 ④ 递增性:ID 必须递增才能让 MySQL 为表创建索引(提高数据库表查询效率的实现)

🍀 ⑤ 安全性:ID 不能过于简单,让用户猜测到

(3) 全局 ID 的结构

🍀 可使用 Redis 的 incr 实现自增

🍀 为了增加 ID 的安全性,不直接使用 Redis 自增的数值,而是拼接一些其它信息

ID 的组成部分:

🍀 符号位:1bit,永远为 0【ID 永远是正数】

🍀 时间戳:31bit,以为单位,可以使用69年

🍀 序列号:32bit,秒内的计数器,支持每秒产生 2^32 个不同 ID

(4) 代码实现

① RedisIdWorker

@Component
@SuppressWarnings("all")
public class RedisIdWorker {
    // 开始时间(秒)
    private static final long BEGIN_DAY_SECONDS;
    // 序列号的长度
    private static final long NO_BITS = 32;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    static {
        BEGIN_DAY_SECONDS = getSecondsOfDate(2020, 5, 20, 5, 20, 20);
    }
    /**
     * @param idPrefix 标识 ID 是哪个业务的
     * @return ID 值
     */
    public long newId(String idPrefix) {
        // 1.生成时间戳
        long seconds = getNowSeconds() - BEGIN_DAY_SECONDS;
        // 2.生成序列号
        // 2.1 当前日期
        String ymd = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 2.2 使用 Redis 生成序列号
        Long no = stringRedisTemplate.opsForValue().increment("icrId:" + idPrefix + ":" + ymd);
        // 把时间戳左移32位, 空出序列号的位置
        return seconds << NO_BITS | no;
    }
    /**
     * 获取某个日期的秒数
     */
    private static long getSecondsOfDate(int y, int month, int d, int h, int min, int sec) {
        LocalDateTime time = LocalDateTime.of(y, month, d, h, min, sec);
        return time.toEpochSecond(ZoneOffset.UTC);
    }
    /**
     * 获取此时此刻的秒数
     */
    private static long getNowSeconds() {
        LocalDateTime curTime = LocalDateTime.now();
        return curTime.toEpochSecond(ZoneOffset.UTC);
    }
}

② Test

@SpringBootTest
class HmDianPingApplicationTests {
    @Resource
    private RedisIdWorker redisIdWorker;
    // 线程池
    private ExecutorService executorService = Executors.newFixedThreadPool(520);
    // 计数器
    private CountDownLatch latch = new CountDownLatch(300);
    @Test
    public void testReidIdWorker() throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                long orderId = redisIdWorker.newId("order");
                System.out.println("orderId = " + orderId);
            }
            latch.countDown(); // 任务执行完就递减
        };
        long begin = System.currentTimeMillis();
        for (int i = 0; i < 300; i++) {
            executorService.submit(task);
        }
        latch.await();
        long end = System.currentTimeMillis();
        System.out.println("duration: " + (end - begin));
    }
}

(5) 全局唯一 ID 其他生成策略

全局唯一ID生成策略:

🍀 UUID

🍀 Redis 自增

🍀 snowflake 算法

🍀 数据库自增(专门用一张表自增 ID)

Redis 自增 ID 策略:

🍀 每天一个 key,方便统计订单量

🍀 ID 结构:时间戳 + 计数器

二、添加优惠券

(1) 数据库

普通券表:

CREATE TABLE `tb_voucher` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `shop_id` bigint(20) unsigned DEFAULT NULL COMMENT '商铺id',
  `title` varchar(255) NOT NULL COMMENT '代金券标题',
  `sub_title` varchar(255) DEFAULT NULL COMMENT '副标题',
  `rules` varchar(1024) DEFAULT NULL COMMENT '使用规则',
  `pay_value` bigint(10) unsigned NOT NULL COMMENT '支付金额,单位是分。例如200代表2元',
  `actual_value` bigint(10) NOT NULL COMMENT '抵扣金额,单位是分。例如200代表2元',
  `type` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '0,普通券;1,秒杀券',
  `status` tinyint(1) unsigned NOT NULL DEFAULT '1' COMMENT '1,上架; 2,下架; 3,过期',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT

```秒杀券表:`

CREATE TABLE `tb_seckill_voucher` (
  `voucher_id` bigint(20) unsigned NOT NULL COMMENT '关联的优惠券的id',
  `stock` int(8) NOT NULL COMMENT '库存',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `begin_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
  `end_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '失效时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`voucher_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT COMMENT='秒杀优惠券表,与优惠券是一对一关系'

(2) 添加优惠券接口

📗 优惠券(或秒杀券)增加完毕后会返回券的 ID

@Override
    @Transactional
    public void addSeckillVoucher(Voucher voucher) {
        // 保存优惠券
        save(voucher);
        // 保存秒杀信息
        SeckillVoucher seckillVoucher = new SeckillVoucher();
        seckillVoucher.setVoucherId(voucher.getId());
        seckillVoucher.setStock(voucher.getStock());
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        seckillVoucher.setEndTime(voucher.getEndTime());
        seckillVoucherService.save(seckillVoucher);
    }
{
  "actualValue": 10000,
  "rules": "全场通用\\n无需预约\\n可无限叠加\\n不兑现、不找零七\\n仅堂食",
  "updateTime": "2022-05-02T10:10:10",
  "title": "100元代金券(大优惠)",
  "type": 1,
  "payValue": 8000,
  "subTitle": "错过再等一年",
  "createTime": "2022-05-10T10:10:10",
  "id": 1,
  "shopId": 1,
  "beginTime": "2023-08-20T10:10:10",
  "endTime": "2023-08-21T23:10:10",
  "stock": 100,
  "status": 1
}

三、优惠券秒杀下单功能

📖 ① 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单

📖 ② 库存是否充足,库存不足则无法下单

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;
    @Override
    @Transactional // 事务
    public Result seckillVoucher(Long voucherId) {
        SeckillVoucher voucherById = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始或结束
        LocalDateTime beginTime = voucherById.getBeginTime();
        LocalDateTime endTime = voucherById.getEndTime();
        LocalDateTime nowTime = LocalDateTime.now();
        if (nowTime.isBefore(beginTime)) {
            return Result.fail("秒杀未开始(no start)");
        }
        if (nowTime.isAfter(endTime)) {
            return Result.fail("秒杀已结束(finish)");
        }
        // 判断库存是否充足
        Integer stock = voucherById.getStock();
        if (stock < 1) {
            return Result.fail("库存不足");
        }
        // 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).update();
        if (success) {
            VoucherOrder voucherOrder = new VoucherOrder();
            long seckillOrderId = redisIdWorker.newId("seckillOrder");
            voucherOrder.setId(seckillOrderId); // 订单 ID
            voucherOrder.setUserId(UserHolder.getUser().getId()); // 用户 ID
            voucherOrder.setVoucherId(voucherId); // 优惠券 ID
            if (save(voucherOrder)) {
                return Result.ok(seckillOrderId);
            }
            return Result.fail("服务器忙, 请稍后再秒杀下单");
        }
        return Result.fail("库存不足");
    }
}

(1) 超卖问题

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁: 超卖问题:

悲观锁

📖 认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行

📖 如 Synchronized、Lock 都属于悲观锁

乐观锁

📖 认为线程安全问题不一定会发生,因此不加锁

📖 在更新数据时去判断有没有其它线程对数据做了修改。

📖 如果没有修改则认为是安全的,自己才更新数据

📖 如果已经被其它线程修改,说明发生了安全问题,此时可以重试或异常

(2) 乐观锁(版本号和 CAS)

乐观锁的关键是判断之前查询得到的数据是否有被修改过

(3) 乐观锁解决超卖问题

四、一人一单功能【☆】

📖 修改秒杀业务,要求同一个优惠券,同一个用户只能下一单

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;
    @Override
    public Result seckillVoucher(Long voucherId) {
        SeckillVoucher voucherById = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始或结束
        LocalDateTime beginTime = voucherById.getBeginTime();
        LocalDateTime endTime = voucherById.getEndTime();
        LocalDateTime nowTime = LocalDateTime.now();
        if (nowTime.isBefore(beginTime)) {
            return Result.fail("秒杀未开始(no start)");
        }
        if (nowTime.isAfter(endTime)) {
            return Result.fail("秒杀已结束(finish)");
        }
        // 判断库存是否充足
        Integer stock = voucherById.getStock();
        if (stock < 1) {
            return Result.fail("库存不足");
        }
        // 一人一单
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            // 获取事务的代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(userId, voucherId);
        }
    }
    @Transactional // 事务
    public Result createVoucherOrder(Long userId, Long voucherId) {
        Integer count = query().eq("user_id", userId)
                .eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("一人只能下一单");
        }
        // 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .ge("stock", 0) // 保证库存大于零(CAS 乐观锁)
                .update();
        if (success) {
            VoucherOrder voucherOrder = new VoucherOrder();
            long seckillOrderId = redisIdWorker.newId("seckillOrder");
            voucherOrder.setId(seckillOrderId); // 订单 ID
            voucherOrder.setUserId(userId); // 用户 ID
            voucherOrder.setVoucherId(voucherId); // 优惠券 ID
            if (save(voucherOrder)) {
                return Result.ok(seckillOrderId);
            }
            return Result.fail("服务器忙, 请稍后再秒杀下单");
        }
        return Result.fail("库存不足");
    }
}

五、并发情况下的线程安全问题

worker_processes  1;
events {
    worker_connections  1024;
}
http {
    include       mime.types;
    default_type  application/json;
    sendfile        on;
    
    keepalive_timeout  65;
    server {
        listen       8080;
        server_name  localhost;
        # 指定前端项目所在的位置
        location / {
            root   html/hmdp;
            index  index.html index.htm;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
        location /api {  
            default_type  application/json;
            #internal;  
            keepalive_timeout   30s;  
            keepalive_requests  1000;  
            #支持keep-alive  
            proxy_http_version 1.1;  
            rewrite /api(/.*) $1 break;  
            proxy_pass_request_headers on;
            #more_clear_input_headers Accept-Encoding;  
            proxy_next_upstream error timeout;  
            #proxy_pass http://127.0.0.1:8081;
            proxy_pass http://backend;
        }
    }
    upstream backend {
        server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
        server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
    }  
}

集群模式下,每个 JVM 都有自己的锁监视器

每个 JVM 的锁监视器互相不可见

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
1月前
|
算法 关系型数据库 MySQL
分布式唯一ID生成:深入理解Snowflake算法在Go中的实现
在分布式系统中,确保每个节点生成的 ID 唯一且高效至关重要。Snowflake 算法由 Twitter 开发,通过 64 位 long 型数字生成全局唯一 ID,包括 1 位标识位、41 位时间戳、10 位机器 ID 和 12 位序列号。该算法具备全局唯一性、递增性、高可用性和高性能,适用于高并发场景,如电商促销时的大量订单生成。本文介绍了使用 Go 语言的 `bwmarrin/snowflake` 和 `sony/sonyflake` 库实现 Snowflake 算法的方法。
44 1
分布式唯一ID生成:深入理解Snowflake算法在Go中的实现
|
18天前
|
存储 NoSQL Java
使用lock4j-redis-template-spring-boot-starter实现redis分布式锁
通过使用 `lock4j-redis-template-spring-boot-starter`,我们可以轻松实现 Redis 分布式锁,从而解决分布式系统中多个实例并发访问共享资源的问题。合理配置和使用分布式锁,可以有效提高系统的稳定性和数据的一致性。希望本文对你在实际项目中使用 Redis 分布式锁有所帮助。
47 5
|
21天前
|
NoSQL Java 数据处理
基于Redis海量数据场景分布式ID架构实践
【11月更文挑战第30天】在现代分布式系统中,生成全局唯一的ID是一个常见且重要的需求。在微服务架构中,各个服务可能需要生成唯一标识符,如用户ID、订单ID等。传统的自增ID已经无法满足在集群环境下保持唯一性的要求,而分布式ID解决方案能够确保即使在多个实例间也能生成全局唯一的标识符。本文将深入探讨如何利用Redis实现分布式ID生成,并通过Java语言展示多个示例,同时分析每个实践方案的优缺点。
39 8
|
1月前
|
NoSQL 算法 关系型数据库
分布式 ID 详解 ( 5大分布式 ID 生成方案 )
本文详解分布式全局唯一ID及其5种实现方案,关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
分布式 ID 详解 ( 5大分布式 ID 生成方案 )
|
1月前
|
NoSQL Redis
Redis分布式锁如何实现 ?
Redis分布式锁通过SETNX指令实现,确保仅在键不存在时设置值。此机制用于控制多个线程对共享资源的访问,避免并发冲突。然而,实际应用中需解决死锁、锁超时、归一化、可重入及阻塞等问题,以确保系统的稳定性和可靠性。解决方案包括设置锁超时、引入Watch Dog机制、使用ThreadLocal绑定加解锁操作、实现计数器支持可重入锁以及采用自旋锁思想处理阻塞请求。
57 16
|
1月前
|
缓存 NoSQL PHP
Redis作为PHP缓存解决方案的优势、实现方式及注意事项。Redis凭借其高性能、丰富的数据结构、数据持久化和分布式支持等特点,在提升应用响应速度和处理能力方面表现突出
本文深入探讨了Redis作为PHP缓存解决方案的优势、实现方式及注意事项。Redis凭借其高性能、丰富的数据结构、数据持久化和分布式支持等特点,在提升应用响应速度和处理能力方面表现突出。文章还介绍了Redis在页面缓存、数据缓存和会话缓存等应用场景中的使用,并强调了缓存数据一致性、过期时间设置、容量控制和安全问题的重要性。
40 5
|
2月前
|
NoSQL Java Redis
太惨痛: Redis 分布式锁 5个大坑,又大又深, 如何才能 避开 ?
Redis分布式锁在高并发场景下是重要的技术手段,但其实现过程中常遇到五大深坑:**原子性问题**、**连接耗尽问题**、**锁过期问题**、**锁失效问题**以及**锁分段问题**。这些问题不仅影响系统的稳定性和性能,还可能导致数据不一致。尼恩在实际项目中总结了这些坑,并提供了详细的解决方案,包括使用Lua脚本保证原子性、设置合理的锁过期时间和使用看门狗机制、以及通过锁分段提升性能。这些经验和技巧对面试和实际开发都有很大帮助,值得深入学习和实践。
太惨痛: Redis 分布式锁 5个大坑,又大又深, 如何才能 避开 ?
|
4月前
|
NoSQL Redis
基于Redis的高可用分布式锁——RedLock
这篇文章介绍了基于Redis的高可用分布式锁RedLock的概念、工作流程、获取和释放锁的方法,以及RedLock相比单机锁在高可用性上的优势,同时指出了其在某些特殊场景下的不足,并提到了ZooKeeper作为另一种实现分布式锁的方案。
131 2
基于Redis的高可用分布式锁——RedLock
|
4月前
|
缓存 NoSQL Java
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解分布式情况下如何添加分布式锁 【续篇】
这篇文章是关于如何在SpringBoot应用中整合Redis并处理分布式场景下的缓存问题,包括缓存穿透、缓存雪崩和缓存击穿。文章详细讨论了在分布式情况下如何添加分布式锁来解决缓存击穿问题,提供了加锁和解锁的实现过程,并展示了使用JMeter进行压力测试来验证锁机制有效性的方法。
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解分布式情况下如何添加分布式锁 【续篇】
|
2月前
|
缓存 NoSQL Java
大数据-50 Redis 分布式锁 乐观锁 Watch SETNX Lua Redisson分布式锁 Java实现分布式锁
大数据-50 Redis 分布式锁 乐观锁 Watch SETNX Lua Redisson分布式锁 Java实现分布式锁
71 3
大数据-50 Redis 分布式锁 乐观锁 Watch SETNX Lua Redisson分布式锁 Java实现分布式锁
下一篇
DataWorks