vlambda博客
学习文章列表

redis+lua生成分布式自增单号


问题:

最近在做价格系统的同步功能,简单描述下需求就是多家商城会跟随平台的采购成本和设置的毛利率而自动调整。每一次调整会生成批次记录。在测试环节发现生成的调价记录单号有重复的,我的第一判断是幂等没有控制好出现了重复插入的数据。经过排查发现,虽然单号是相同的但是它们的调价明细并不相同,由此可以排除幂等问题,它们并不是两条重复数据。翻看实现代码发现同事写的生成单号的规则直接用的时间戳代码如下:

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");Long adjustNo = Long.valueOf(formatter.format(LocalDateTime.now()));

很明显在并发场景下如果两个单号在同一时刻(毫秒级)生成则会出现重复。如何解决这个问题?


分析:

生成单号是一个常规需求,单号作为单据信息的唯一标识,在很多业务场景中都需要生成单号,例如用户下单的订单号,设置记录的批次号,发货信息的发货单号等等。生成单号的策略要考虑到两个因素:

1、业务的并发量

2、未来的业务的增长量


生成的单号一般要满足以下三个条件:

1、唯一性,即每次生成的单号不能重复

2、长度固定,不能太长又要满足业务增长需要。

3、屏蔽业务信息,即生成规则中不要加入业务信息不能被遍历。


通过分析发现单号长度固定,屏蔽业务信息比较容易实现,解决问题的关键是如何保证在并发场景下生成单号的唯一性。


实现:

利用数据库自增Id和使用UUID的方式就不做论述了,它们都有着明显的缺点。用数据库自增Id不但要依赖数据库而且暴露业务增长情况,UUID过长且不优雅。我最终选用的方案是:时间戳+redis自增数


时间戳很好理解,就是取当前精确到毫秒的字符串这样在不同毫秒外产生的单号肯定不会重复。同一毫秒内的单号通过redis内的循环自增数防止重复。由此这个问题进一步细化到了如何通过redis生成循环自增数。


评估业务并发量把自增Id设置在10~99之间循环自增,即在1毫秒可以满足90个线程并发量(90tps)。具体代码如下:

public Long getSequenceNumber() { int max = 99; int sequence; if (stringRedisTemplate.hasKey(sequenceKey)) { int currentSequence = Integer.valueOf(stringRedisTemplate.opsForValue().get(sequenceKey)); sequence = currentSequence >= max ? 10 : currentSequence + 1; } else { sequence = 10; } stringRedisTemplate.opsForValue().set(sequenceKey, String.valueOf(sequence), 1, TimeUnit.MINUTES); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"); String numberStr = Long.valueOf(formatter.format(LocalDateTime.now())) + sequence; return Long.valueOf(numberStr);}


但是这段代码并不能不能保证生成自增Id代码的原子性和隔离性,例如线程A从redis取到值10,在A线程将值+1并放回redis之前,线程B同样取到为10,重复了。可以用synchronized给代码加锁,如下:

public Long getSequenceNumber() { int max = 99; int sequence; synchronized (stringRedisTemplate) { if (stringRedisTemplate.hasKey(sequenceKey)) { int currentSequence = Integer.valueOf(stringRedisTemplate.opsForValue().get(sequenceKey)); sequence = currentSequence >= max ? 10 : currentSequence + 1; } else { sequence = 10; } stringRedisTemplate.opsForValue().set(sequenceKey, String.valueOf(sequence), 1, TimeUnit.MINUTES); } DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"); String numberStr = Long.valueOf(formatter.format(LocalDateTime.now())) + sequence; return Long.valueOf(numberStr);}


经测这种加锁方式在单体服务中是没有问题的,但是如果是分布式系统就不起作用了。难道还要再引入分布式锁嘛,如果这样就把问题就复杂化了。有没有简单的方式呢?可以利用redis单线程和lua脚本对redis值进行操作来保证自增数的原子性和隔离性,代码如下:

public Long getSequenceNumber() { int max = 99;    String luaScript = "local sequence = redis.call('get', KEYS[1]);" + "if sequence then if sequence>=ARGV[1] then sequence = 10 else sequence = sequence+1 end else sequence = 10 end;" + "redis.call('set', KEYS[1], sequence); return sequence;"; RedisScript<Long> luaScript = new DefaultRedisScript<>(script, Long.class); Long sequence = stringRedisTemplate.execute(luaScript, Collections.singletonList(sequenceKey), String.valueOf(max)); String numberStr = LocalDateTimeUtils.format(LocalDateTime.now(), LocalDateTimeUtils.DateFormat.DATE_OVERLENGTH_PATTERN_NONE) + sequence; return Long.valueOf(numberStr);}

这里再科普下lua脚本,Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。reids也支持这一脚本语言,整个脚本函数作为原子操作执行。


啰里啰唆写了这么多,更多的是分享下解决问题的过程,过程比结果更有趣。人生重要的同样不是结果而是过程,当然结果也重要。





另附lua操作redis常用函数:

redis.call('get', KEYS[1]); 取值redis.call('del', KEYS[1]); 删值redis.call('set', KEYS[1], val); 删值redis.call("incr","count");自动+1redis.call('expire',KEYS[1], time);设置超时时间tonumber(ARGV[1]) 字符转化为数字tostring(num2) 数字转化为字符if sequence then sequence = 20 else sequence = 10 end;判断是否为null



点点在看行不行