云
9 小时以前 02a1ae81eba7b48223052fb833859f0a65dd7d21
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
131
132
133
134
135
136
137
138
139
140
141
142
package cn.iocoder.yudao.module.mes.dal.redis;
 
import cn.iocoder.yudao.framework.common.util.number.BizNoUtils;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Repository;
 
import java.util.ArrayList;
import java.util.List;
 
/**
 * MES 业务编码生成的 Redis DAO(实例码:袋码 / 托盘码 / 临时码等)。
 *
 * <p>需求:所有业务编码统一为「16 位纯数字」,且全系统唯一。
 * 这里复用全系统统一的 Redis 自增 KEY,保证与其它模块的编码也不重复。
 */
@Repository
public class MesCodeRedisDAO {
 
    /**
     * 袋序号自增 KEY(全局袋序号,保证同一批/跨批生成时不重复)
     */
    private static final String BAG_NO_SEQ_KEY = "mes:bag_no:seq";
 
    /**
     * 单次批量取码的最大个数
     * <p>
     * 受 Redis 单条命令参数规模与响应体大小约束,过大反而增加单次阻塞时间。
     */
    public static final int MAX_BATCH_SIZE = 5000;
 
    /**
     * 批量写入并返回实际生效的编码。
     * <p>
     * 逐个 SADD 在同一脚本内完成:与调用方逐条 SADD 相比,网络往返从 N 次降为 1 次;
     * 同时因为脚本原子执行,并发调用之间也不会产生重复编码。
     */
    private static final RedisScript<List> ADD_UNIQUE_CODES_SCRIPT = new DefaultRedisScript<>(
            "local res = {} "
                    + "for i = 1, #ARGV do "
                    + "  if redis.call('SADD', KEYS[1], ARGV[i]) == 1 then "
                    + "    res[#res + 1] = ARGV[i] "
                    + "  end "
                    + "end "
                    + "return res",
            List.class);
 
    /**
     * 原子分配袋序号区间的 Lua 脚本。
     * <p>
     * 先按库内最大序号校正基准值(Redis 首次使用、或库内数据被直接导入导致基准落后时),
     * 再原子自增并返回自增后的值。整个「校正 + 自增」在 Redis 单线程内完成,避免并发下互相覆盖计数。
     * <p>
     * KEYS[1] = 序号 KEY;ARGV[1] = 本次需要的个数;ARGV[2] = 库内当前最大序号
     */
    private static final RedisScript<Long> ALLOCATE_BAG_NO_SCRIPT = new DefaultRedisScript<>(
            "local cur = redis.call('GET', KEYS[1]) "
                    + "local base = tonumber(ARGV[2]) "
                    + "if (not cur) or (tonumber(cur) < base) then redis.call('SET', KEYS[1], base) end "
                    + "return redis.call('INCRBY', KEYS[1], ARGV[1])",
            Long.class);
 
    @Resource
    private StringRedisTemplate stringRedisTemplate;
 
    /**
     * 生成一个 16 位纯数字业务编码(全系统唯一)
     *
     * @return 16 位纯数字编码
     */
    public String generate() {
        // 随机生成 16 位纯数字编码,并用 Redis 集合查重,保证全系统唯一(不使用自增/顺序)
        for (int i = 0; i < BizNoUtils.MAX_RETRY; i++) {
            String code = BizNoUtils.randomCode();
            Long added = stringRedisTemplate.opsForSet().add(BizNoUtils.USED_SET_KEY, code);
            if (added != null && added > 0) {
                return code;
            }
        }
        throw new IllegalStateException("生成唯一业务编码失败,请重试");
    }
 
    /**
     * 批量生成 count 个唯一的 16 位纯数字业务编码(全系统唯一)
     * <p>
     * 大批量生成时不要循环调用 {@link #generate()}:那会产生 N 次 Redis 往返,
     * 几十万条即可把接口拖到分钟级。这里每批最多 {@link #MAX_BATCH_SIZE} 个候选码
     * 一次脚本写入,碰撞(概率极低)的缺额再补一批。
     *
     * @param count 需要的编码个数
     * @return 唯一编码列表,顺序无保证但数量等于 count
     */
    public List<String> generateBatch(int count) {
        if (count <= 0) {
            return List.of();
        }
        List<String> result = new ArrayList<>(Math.min(count, 1024));
        // 16 位随机码空间为 10^16,正常情况下一轮即可全部写入;限制轮次只用于兜底异常
        for (int round = 0; round < 100 && result.size() < count; round++) {
            int batchSize = Math.min(count - result.size(), MAX_BATCH_SIZE);
            List<String> candidates = new ArrayList<>(batchSize);
            for (int i = 0; i < batchSize; i++) {
                candidates.add(BizNoUtils.randomCode());
            }
            List<?> added = stringRedisTemplate.execute(ADD_UNIQUE_CODES_SCRIPT,
                    List.of(BizNoUtils.USED_SET_KEY), candidates.toArray(new String[0]));
            if (added != null) {
                added.forEach(code -> result.add(String.valueOf(code)));
            }
        }
        if (result.size() < count) {
            throw new IllegalStateException("批量生成唯一业务编码失败,请重试");
        }
        return result;
    }
 
    /**
     * 原子分配一段连续的袋序号,返回区间起始值。
     * <p>
     * 袋序号原先由「查库内最大值再 +1」得到,并发生成时两个请求会读到同一个最大值,
     * 从而产出重复的袋序号(袋序号会印在标签上)。此处改为 Redis 原子自增分配,
     * 并用库内当前最大值做基准校正,保证序号既不重复也不回退。
     *
     * @param count      本次需要的连续序号个数(须大于 0)
     * @param dbMaxBagNo 数据库当前最大袋序号(含已逻辑删除记录,传 null 视为 0)
     * @return 区间起始序号(含),可用区间为 [start, start + count - 1]
     */
    public int allocateBagNoStart(int count, Integer dbMaxBagNo) {
        if (count <= 0) {
            throw new IllegalArgumentException("袋序号分配个数必须大于 0");
        }
        Long end = stringRedisTemplate.execute(ALLOCATE_BAG_NO_SCRIPT, List.of(BAG_NO_SEQ_KEY),
                String.valueOf(count), String.valueOf(dbMaxBagNo == null ? 0 : dbMaxBagNo));
        if (end == null) {
            throw new IllegalStateException("分配袋序号失败,请重试");
        }
        return (int) (end - count + 1);
    }
 
}