前言
最近学习了利用redis解决许多业务问题的方法,优惠券秒杀是其中一个综合性较强的业务问题。
全局唯一id
雪花算法:雪花算法的原理就是生成一个的 64 位比特位的 long 类型的唯一 id。id组成为符号位(1bit)、时间戳(41bits)、工作机器ID(10bits)、序列号(12bits)。雪花算法能基于时间戳生成自增的id,但正是因为基于时间戳,雪花算法有可能出现时间(时钟)回拨的问题。
组成:
1:最高 1 位是符号位,固定值 0,表示id 是正整数
41:接下来 41 位存储毫秒级时间戳,2^41/(1000606024365)=69,大概可以使用 69 年。
10:再接下 10 位存储机器码,包括 5 位 datacenterId 和 5 位 workerId。最多可以部署 2^10=1024 台机器。
12:最后 12 位存储序列号。同一毫秒时间戳时,通过这个递增的序列号来区分。即对于同一台机器而言,同一毫秒时间戳下,可以生成 2^12=4096 个不重复 id。
基于雪花算法生成id:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130
|
public class SnowflakeIdGenerator { private static final long EPOCH = 1672531200000L;
private static final long WORKER_ID_BITS = 10L; private static final long SEQUENCE_BITS = 12L;
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);
private static final long TIMESTAMP_SHIFT = WORKER_ID_BITS + SEQUENCE_BITS; private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
private final long workerId; private long sequence = 0L; private long lastTimestamp = -1L;
private static final long MAX_CLOCK_BACKWARD_MS = 5;
public SnowflakeIdGenerator(long workerId) { if (workerId < 0 || workerId > MAX_WORKER_ID) { throw new IllegalArgumentException( String.format("Worker ID must be between 0 and %d", MAX_WORKER_ID)); } this.workerId = workerId; }
public synchronized long nextId() { long timestamp = currentTime();
if (timestamp < lastTimestamp) { long offset = lastTimestamp - timestamp; if (offset <= MAX_CLOCK_BACKWARD_MS) { try { wait(offset); timestamp = currentTime(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("Clock moved backwards, wait interrupted", e); } } else { throw new RuntimeException( String.format("Clock moved backwards. Refusing to generate ID for %d milliseconds", offset)); } }
if (lastTimestamp == timestamp) { sequence = (sequence + 1) & MAX_SEQUENCE; if (sequence == 0) { timestamp = tilNextMillis(lastTimestamp); } } else { sequence = 0L; }
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << TIMESTAMP_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence; }
private long currentTime() { return System.currentTimeMillis(); }
private long tilNextMillis(long lastTimestamp) { long timestamp = currentTime(); while (timestamp <= lastTimestamp) { timestamp = currentTime(); } return timestamp; }
public static long parseTimestamp(long id) { return (id >> TIMESTAMP_SHIFT) + EPOCH; }
public static long parseWorkerId(long id) { return (id >> WORKER_ID_SHIFT) & MAX_WORKER_ID; }
public static long parseSequence(long id) { return id & MAX_SEQUENCE; }
|
在优惠券业务中,我们需要向客户端返回订单号,这个订单号在sql中作为订单表的主键,处于安全性等考虑我们不希望订单号在sql表中简单的自增。在redis中以字符串为key的键值对,key中的字符串具有不同的哈希值,且其值是自增的,我们可以利用这一特性制造全局唯一id。
基于redis生成id组成如下:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| @Component public class RedisIdWorker {
private static final long BEGIN_TIMESTAMP = 1640995200L;
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; }
public long nextId(String keyPrefix) { LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSecond - BEGIN_TIMESTAMP;
String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
return timestamp << COUNT_BITS | count; } }
|
秒杀下单
在秒杀下单这一场景下我们后端需要处理许多高并发的业务问题,如超卖问题、一人一单问题等。
主要逻辑流程图:

基础代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| @Override @Transactional public Result seckillVoucher(Long voucherId) { SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒杀尚未开始"); } if (voucher.getBeginTime().isBefore(LocalDateTime.now())) { return Result.fail("秒杀已经结束"); } if (voucher.getStock() < 1) { return Result.fail("库存不足"); } boolean success = seckillVoucherService.update() .setSql("stock = stock - 1") .eq("voucher_id", voucherId).update(); if(!success){ return Result.fail("库存不足"); } VoucherOrder voucherOrder = new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); Long userId = UserHolder.getUser().getId(); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); save(voucherOrder); return Result.ok(orderId); }
|
超卖问题
在发放优惠券的时候,如果出现多个用户同时进入抢票程序,且由于当时数据库仍有余票未更新,这会导致卖出的票与预想的要多,这在生产环境是十分危险的,为解决并发问题,我们可以利用悲观锁与乐观锁两种思路。
- 悲观锁:悲观锁认为随时都会有其他线程抢占资源,当前线程获取锁后,其他线程将无法对正常运行。
- 乐观锁:乐观锁认为不会有其他线程抢占资源,当前线程与其他线程不会互斥,他们将正常访问资源,但是在更新资源前后都会进行一次比较,若前后的结果不一致,则认为有其他线程更改了资源,当前线程会认为该数据不可用。
基于乐观锁的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| @Override @Transactional public Result seckillVoucher(Long voucherId) { SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒杀尚未开始"); } if (voucher.getBeginTime().isBefore(LocalDateTime.now())) { return Result.fail("秒杀已经结束"); } if (voucher.getStock() < 1) { return Result.fail("库存不足"); } boolean success = seckillVoucherService.update() .setSql("stock = stock - 1") .eq("voucher_id", voucherId) .gt("stock", 0).update(); if(!success){ return Result.fail("库存不足"); } VoucherOrder voucherOrder = new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); Long userId = UserHolder.getUser().getId(); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); save(voucherOrder); return Result.ok(orderId); }
|
一人一单问题
我们希望优惠券的策略是一人一票,不允许购买多次。实现的方式很简单,我们只需要在对数据库更新前根据用户id访问数据库,如果没有查到记录则认为该用户是第一次购买优惠券,反之则拒绝。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| @Override @Transactional public Result seckillVoucher(Long voucherId) { SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒杀尚未开始"); } if (voucher.getBeginTime().isBefore(LocalDateTime.now())) { return Result.fail("秒杀已经结束"); } if (voucher.getStock() < 1) { return Result.fail("库存不足"); } Long userId = UserHolder.getUser().getId(); int 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) .gt("stock", 0).update(); if(!success){ return Result.fail("库存不足"); } VoucherOrder voucherOrder = new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); save(voucherOrder); return Result.ok(orderId); }
|
其实不难想到上述代码存在的并发问题,如果有人利用数据库录入该账户信息这段时间,多次请求优惠券,这些请求也将被视为合法,因为数据库不能查询到该用户的信息。我们同样利用锁机制解决这个问题,大致思想就是在进行写数据时加一把锁,其他线程的请求将被拒绝,这样就可以放心的更新数据库。
基于悲观锁(synchronized)实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| @Override public Result seckillVoucher(Long voucherId) { SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒杀尚未开始"); } if (voucher.getBeginTime().isBefore(LocalDateTime.now())) { return Result.fail("秒杀已经结束"); } if (voucher.getStock() < 1) { return Result.fail("库存不足"); } Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()){ IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return createSeckillVoucher(voucherId); } }
@Transactional public Result createSeckillVoucher(Long voucherId){ boolean success = seckillVoucherService.update() .setSql("stock = stock - 1") .eq("voucher_id", voucherId) .gt("stock", 0).update(); if(!success){ return Result.fail("库存不足"); } VoucherOrder voucherOrder = new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); Long userId = UserHolder.getUser().getId(); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); save(voucherOrder); return Result.ok(orderId); }
|
集群服务器
其实刚刚我们所有场景都是在单机模式下进行的,现阶段的企业开发一般会部署多台服务器来处理请求,但是我们的锁只能在本机中可见,其他服务器是看不到的,也就是说,当我们操作数据库时,其他请求可能通过其他服务器去访问数据库,这样一来又会出现并发问题。我们需要借助分布式锁解决这个问题,分布式锁有很多实现方式,我们采用redis实现。其实这里又涉及许多并发问题,详细可以去b站上看是怎么一步步完善分布式锁的。
实战篇-09.分布式锁-基本原理和不同实现方式对比_哔哩哔哩_bilibili
Lua 基本语法 | 菜鸟教程
基于redis的分布式锁实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| @Override public Result seckillVoucher(Long voucherId) { SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒杀尚未开始"); } if (voucher.getBeginTime().isBefore(LocalDateTime.now())) { return Result.fail("秒杀已经结束"); } if (voucher.getStock() < 1) { return Result.fail("库存不足"); } Long userId = UserHolder.getUser().getId(); SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate); boolean isLock = lock.tryLock(1200); if(!isLock){ return Result.fail("不允许重复下单"); } try { IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return createSeckillVoucher(voucherId); }finally { lock.unlock(); } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| public class SimpleRedisLock implements ILock {
private String name; private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) { this.name = name; this.stringRedisTemplate = stringRedisTemplate; }
private static final String KEY_PREFIX = "lock:"; private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; static { UNLOCK_SCRIPT = new DefaultRedisScript<>(); UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); UNLOCK_SCRIPT.setResultType(Long.class); }
@Override public boolean tryLock(long timeoutSec) { String threadId = ID_PREFIX + Thread.currentThread().getId(); Boolean success = stringRedisTemplate.opsForValue(). setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); }
@Override public void unlock() { stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name), ID_PREFIX + Thread.currentThread().getId()); }
|
到这里我们的锁就是一个勉强可以用的分布式锁了,当然他仍然存在许多问题,感兴趣的可以继续学习基于redisson实现分布式锁。
实战篇-18.分布式锁-Redisson快速入门_哔哩哔哩_bilibili