目录
全局ID生成器
设计目的
-
唯一性保障 确保在分布式环境下生成的ID全局唯一,避免因多节点并发导致的数据冲突。
-
有序性支持 生成趋势递增的ID,便于数据库索引优化(如InnoDB的B+树索引更高效)。
-
高性能生成 满足高并发场景下的ID生成需求,避免成为系统瓶颈。
-
兼容业务需求 支持ID中包含时间戳、分片信息等业务相关字段,便于数据路由或分析。
核心解决的问题
1. 分布式环境下的ID冲突
-
问题:多节点独立生成ID时,可能因时钟不同步或算法缺陷导致重复。
-
解决:通过时间戳、机器ID、序列号等机制组合生成唯一ID(如Snowflake算法)。
2. 高并发场景的性能瓶颈
-
问题:传统数据库自增ID在高并发下性能不足,且存在单点故障风险。
-
解决:使用本地预生成ID段(如号段模式)或内存数据库(如Redis)提高吞吐量。
3. 分库分表的数据路由
-
问题:数据分片后,需通过ID快速定位目标库表,避免全库扫描。
-
解决:在ID中嵌入分片信息(如库号、表号),实现高效路由。
4. 业务可读性与功能性需求
-
问题:业务可能需要通过ID解析时间、来源等元数据。
-
解决:设计结构化ID(如时间戳高位、机器ID中位、序列号低位)。
5. 时钟回拨风险
-
问题:服务器时钟回退可能导致基于时间的ID重复。
-
解决:引入时钟同步机制(如NTP)或异常处理(如等待时钟追平)。
使用Redis封装一个全局Id生成器
@Component
public class RedisWorker {
public static final long BEGIN_TIMESTAMP = 1735689600;
@Autowired
private StringRedisTemplate stringRedisTemplate;
public long nextId(String keyPrefix){
// 生成时间戳
long timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) - BEGIN_TIMESTAMP;
// 获得自增序列号
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long count = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + date);
// 拼接返回
return timestamp<<32|count;
}
}
测试方法代码
void testIdWorker() throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisWorker.nextId("order");
System.out.println(id);
}
countDownLatch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
countDownLatch.await();
long end = System.currentTimeMillis();
System.out.println("time:" + (end - begin));
}
线程池
在 testIdWorker
测试方法里使用线程池,主要是为了模拟高并发场景,以此验证 RedisWorker
类的 nextId
方法在高并发环境下能否正确生成唯一 ID。下面详细阐述线程池在该场景中的作用:
1. 模拟高并发
在实际业务里,可能会有大量并发请求同时需要生成唯一 ID。使用线程池可以模拟这种高并发场景,让多个线程同时调用 RedisWorker
的 nextId
方法,以此检验该方法在高并发情况下的性能和正确性,查看是否会出现 ID 重复的问题。示例代码中的循环提交任务逻辑如下:
for (int i = 0; i < 300; i++) {
es.submit(task);
}
这表示将同一个任务提交 300 次到线程池,线程池会调度多个线程并发执行该任务,从而模拟 300 个并发请求。
2. 提高测试效率
创建和销毁线程是比较耗费系统资源的操作。如果每次请求都创建一个新线程,会消耗大量的时间和系统资源,导致测试效率低下。而线程池可以复用已创建的线程,当一个任务执行完毕后,线程不会被销毁,而是等待下一个任务,这样能显著减少线程创建和销毁的开销,提高测试效率。
3. 控制并发线程数量
线程池可以控制并发线程的数量,避免因创建过多线程导致系统资源耗尽。示例代码中使用 Executors.newFixedThreadPool(500)
创建了一个固定大小为 500 的线程池,这意味着最多同时有 500 个线程在执行任务,保证了系统的稳定性。
public static final ExecutorService es = Executors.newFixedThreadPool(500);
4. 便于管理线程
线程池提供了一系列管理线程的方法,例如可以方便地关闭线程池、监控线程池的状态等。在测试完成后,可以调用线程池的 shutdown
方法来关闭线程池,释放资源。
es.shutdown();
综上所述,使用线程池可以更高效、更真实地模拟高并发场景,同时提高测试效率、控制并发线程数量以及便于管理线程,从而更好地验证 RedisWorker
类 nextId
方法的并发性能和正确性。
CountDownLatch
的具体作用
CountDownLatch
是 Java 并发包 java.util.concurrent
里的一个同步辅助类,在当前代码中,它的主要作用是确保主线程等待所有子线程执行完毕后,再继续执行后续操作,从而准确统计多线程并发执行任务的总耗时。下面结合代码详细解释。
1. 初始化计数器
CountDownLatch countDownLatch = new CountDownLatch(300);
创建 CountDownLatch
实例并将计数器初始化为 300。这个计数器代表需要等待执行完成的子线程数量。因为后续会向线程池提交 300 个任务,所以将计数器设为 300。
2. 子线程任务完成通知
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisWorker.nextId("order");
System.out.println(id);
}
countDownLatch.countDown();
};
在每个子线程的任务中,当任务执行完毕后,调用 countDownLatch.countDown()
方法。该方法会将 CountDownLatch
的计数器减 1,表示一个子线程任务已经完成。
3. 主线程等待
countDownLatch.await();
主线程调用 countDownLatch.await()
方法进入等待状态,直到 CountDownLatch
的计数器变为 0。也就是说,主线程会等待所有 300 个子线程都执行完任务并调用 countDown()
方法后,才会继续执行后续代码。
4. 统计总耗时
long begin = System.currentTimeMillis();
// ... 提交任务 ...
countDownLatch.await();
long end = System.currentTimeMillis();
System.out.println("time:" + (end - begin));
通过记录开始时间 begin
和结束时间 end
,可以准确计算出 300 个子线程并发执行任务所花费的总时间。由于使用了 CountDownLatch
,确保了 end
时间是在所有子线程都执行完毕后记录的,这样统计出的耗时是准确的。
总结
在这个测试方法中,CountDownLatch
充当了协调主线程和子线程的角色,保证主线程在所有子线程任务执行完成后才继续执行,从而能够精确统计多线程并发执行 RedisWorker.nextId
方法的总耗时。
秒杀优惠卷
代码实现
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
RedisWorker redisWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 查优惠卷信息
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
if (seckillVoucher == null) {
return Result.fail("优惠卷不存在");
}
// 判断秒杀时间
LocalDateTime now = LocalDateTime.now();
if(seckillVoucher.getBeginTime().isAfter(now)){
return Result.fail("秒杀尚未开始");
}
if(seckillVoucher.getEndTime().isBefore(now)){
return Result.fail("秒杀已经结束");
}
// 查询库存
if(seckillVoucher.getStock()<0){
return Result.fail("库存不足");
}
// 扣减库存
boolean isSuccess = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
if(!isSuccess){
return Result.fail("库存不足");
}
// 创建订单
long id = redisWorker.nextId("seckillVoucherOrder");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(id);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setCreateTime(LocalDateTime.now());
save(voucherOrder);
return Result.ok(id);
}
}
在高并发场景下,多个线程或请求同时对商品库存进行查询和扣减操作,由于操作的非原子性,可能会导致超卖。
超卖问题
乐观锁
乐观锁假设在大多数操作下并发操作不会产生冲突,因此在操作数据是不会对数据加锁。只有在更新数据时会判断当前数据是否被修改过,如果被修改过则放弃本次操作或重试。
乐观锁实现方式
版本号机制
在数据库表中添加一个 version
字段,用于记录数据的版本信息。每次更新数据时,先读取当前数据的版本号,在更新语句中判断版本号是否与读取时一致,如果一致则更新数据并将版本号加 1,否则更新失败。
而在这里只需要在更新时判断库存是否大于0即可
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
RedisWorker redisWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 查优惠卷信息
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
if (seckillVoucher == null) {
return Result.fail("优惠卷不存在");
}
// 判断秒杀时间
LocalDateTime now = LocalDateTime.now();
if(seckillVoucher.getBeginTime().isAfter(now)){
return Result.fail("秒杀尚未开始");
}
if(seckillVoucher.getEndTime().isBefore(now)){
return Result.fail("秒杀已经结束");
}
// 查询库存
if(seckillVoucher.getStock()<0){
return Result.fail("库存不足");
}
// 扣减库存
boolean isSuccess = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0).update();
if(!isSuccess){
return Result.fail("库存不足");
}
// 创建订单
long id = redisWorker.nextId("seckillVoucherOrder");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(id);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setCreateTime(LocalDateTime.now());
save(voucherOrder);
return Result.ok(id);
}
}
悲观锁
悲观锁是一种保守的并发控制策略,它假设在并发环境下,不同线程对同一资源进行操作很可能发生并发安全问题。因此在操作资源前,会对该资源加锁,阻塞其他线程对资源的操作,直到事务提交释放锁。
实现代码
基于 MySQL 的 SELECT ... FOR UPDATE
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisWorker redisWorker;
@Autowired
private VoucherOrderMapper voucherOrderMapper;
@Override
@Transactional(rollbackFor = Exception.class) // 添加事务管理
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 1. 查询订单(解决一人一单问题)
int count = voucherOrderMapper.selectCount(new QueryWrapper<VoucherOrder>()
.eq("user_id", userId)
.eq("voucher_id", voucherId));
if (count > 0) {
return Result.fail("不可重复购买");
}
// 2. 使用悲观锁查询库存(关键修改点)
SeckillVoucher seckillVoucher = seckillVoucherService.getBaseMapper()
.selectOne(new QueryWrapper<SeckillVoucher>()
.eq("voucher_id", voucherId)
.last("FOR UPDATE")); // 添加行级锁
if (seckillVoucher == null) {
return Result.fail("优惠券不存在");
}
// 3. 校验秒杀时间
LocalDateTime now = LocalDateTime.now();
if (seckillVoucher.getBeginTime().isAfter(now)) {
return Result.fail("秒杀尚未开始");
}
if (seckillVoucher.getEndTime().isBefore(now)) {
return Result.fail("秒杀已经结束");
}
// 4. 校验库存
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.update();
if (!success) {
return Result.fail("库存不足");
}
// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(redisWorker.nextId("seckillVoucherOrder"));
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setCreateTime(now);
save(voucherOrder);
return Result.ok(voucherOrder.getId());
}
}
FOR UPDATE
在 MySQL 中的原理
1. 事务与锁的关系
在 MySQL 里,FOR UPDATE
必须在事务中使用。当事务执行包含 FOR UPDATE
的查询语句时,数据库会对查询结果集中的记录加锁。只有当事务提交(COMMIT
)或者回滚(ROLLBACK
)时,锁才会被释放。示例代码如下:
-- 开启事务
START TRANSACTION;
-- 使用 FOR UPDATE 对查询结果加锁
SELECT * FROM seckill_voucher WHERE voucher_id = 1 FOR UPDATE;
-- 执行其他业务逻辑,如更新库存
UPDATE seckill_voucher SET stock = stock - 1 WHERE voucher_id = 1;
-- 提交事务,释放锁
COMMIT;
2. 锁的类型
在 MySQL 的 InnoDB 存储引擎中,FOR UPDATE
通常会加行级锁。行级锁是粒度最小的锁,它只对查询结果集中的具体记录加锁,而不会影响其他记录。这样可以最大程度地减少锁的竞争,提高并发性能。
3. 锁的获取与阻塞
当一个事务执行 FOR UPDATE
语句时,数据库会尝试对相关记录加锁。如果这些记录当前没有被其他事务锁定,那么锁会被成功获取,事务可以继续执行后续操作。如果这些记录已经被其他事务使用 FOR UPDATE
或其他排他锁锁定,那么当前事务会进入阻塞状态,直到持有锁的事务提交或回滚释放锁。
4. 死锁问题
由于 FOR UPDATE
会导致事务阻塞等待锁,因此在高并发场景下可能会出现死锁问题。死锁是指两个或多个事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。例如,事务 A 持有记录 X 的锁并等待记录 Y 的锁,而事务 B 持有记录 Y 的锁并等待记录 X 的锁,此时就会发生死锁。MySQL 的 InnoDB 存储引擎会自动检测死锁,并选择一个事务进行回滚,以打破死锁。
乐观锁与悲观锁对比
-
并发性能
乐观锁:由于操作数据时不加锁,多个事务可以同时读取和修改数据,因此并发性能较高,适合读多写少的场景。 悲观锁:在操作数据前就加锁,同一时间只有一个事务可以操作数据,会导致其他事务阻塞等待,并发性能较低,适合写多读少、对数据一致性要求较高的场景。
-
数据一致性
乐观锁:在更新数据时才检查数据是否被修改,可能会出现更新失败的情况,需要进行重试,数据一致性相对较弱。 悲观锁:通过加锁保证同一时间只有一个事务可以操作数据,能有效避免数据冲突,数据一致性较高。
-
死锁问题
乐观锁:由于不使用锁,不会出现死锁问题。 悲观锁:在高并发场景下,多个事务互相等待锁资源,可能会出现死锁问题。
一人一单
秒杀要求每个用户只能完成一个订单,下面是一人一单要求的解决方案
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
RedisWorker redisWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 查优惠卷信息
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
if (seckillVoucher == null) {
return Result.fail("优惠卷不存在");
}
// 判断秒杀时间
LocalDateTime now = LocalDateTime.now();
if(seckillVoucher.getBeginTime().isAfter(now)){
return Result.fail("秒杀尚未开始");
}
if(seckillVoucher.getEndTime().isBefore(now)){
return Result.fail("秒杀已经结束");
}
// 查询库存
if(seckillVoucher.getStock()<0){
return Result.fail("库存不足");
}
// 检测是否有过订单
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("已经购买过了");
}
// 扣减库存
boolean isSuccess = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0).update();
if(!isSuccess){
return Result.fail("库存不足");
}
// 创建订单
long id = redisWorker.nextId("seckillVoucherOrder");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(id);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setCreateTime(LocalDateTime.now());
save(voucherOrder);
return Result.ok(id);
}
}
在判断库存前通过订单表先判断该用户是否购买过该优惠卷,防止同一用户购买多个优惠卷
但是在高并发场景下,会有多个线程在新增订单事务提交前,进入到了新增订单事务。最终
导致一个用户完成了多个订单的并发安全问题。
悲观锁解决方案
@Override
public Result seckillVoucher(Long voucherId) {
// 前面代码不变
// 提取一人一单,扣减库存,创建订单的代码加锁
return createVoucherOrder(voucherId);
}
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("已经购买过了");
}
// 扣减库存
boolean isSuccess = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0).update();
if (!isSuccess) {
return Result.fail("库存不足");
}
// 创建订单返回订单id
long id = redisWorker.nextId("seckillVoucherOrder");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(id);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setCreateTime(LocalDateTime.now());
save(voucherOrder);
return Result.ok(id);
}
存在问题
在Spring框架中,@Transactional注解借助AOP实现事务管理,当方法被@Transactional修饰之后,Spring会为当前类生成代理对象,在调用方法时执行代理对象中的方法。不过,如果在当前类下调用被@Transactional修饰的方法,就属于自调用,不会经过代理对象,从而使@Transactional注解失效。
解决办法
1.暴露代理对象
在Spring启动类上添加@EnableAspectJAutoProxy(exposeProxy = true)注解,将代理对象暴露出来。
2.调用代理对象中的方法
通过 AopContext.currentProxy()
获取代理对象进行方法调用。
代码实现
@Override
public Result seckillVoucher(Long voucherId) {
// 之前代码不变
// 提取一人一单,扣减库存,创建订单的代码加锁
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
解决了事务自调用的问题之后,我们继续分析代码中存在的问题。
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("已经购买过了");
}
// 扣减库存
boolean isSuccess = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0).update();
if (!isSuccess) {
return Result.fail("库存不足");
}
// 创建订单返回订单id
long id = redisWorker.nextId("seckillVoucherOrder");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(id);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setCreateTime(LocalDateTime.now());
save(voucherOrder);
return Result.ok(id);
}
如果像这样将锁加到方法上,这样会导致整个方法变成一个同步的方法,同时只允许一个线程执行该方法,使得所有线程会串行执行该方法,降低系统性能,所以我们要做的是将锁加到相同的用户上,使得相同用户串行执行,避免并发安全问题,而不同用户受不同锁监视,所以可以并发执行。
@Override
public Result seckillVoucher(Long voucherId) {
// 前面代码不变
// 提取一人一单,扣减库存,创建订单的代码加锁
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, userId);
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("已经购买过了");
}
// 扣减库存
boolean isSuccess = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0).update();
if (!isSuccess) {
return Result.fail("库存不足");
}
// 创建订单
long id = redisWorker.nextId("seckillVoucherOrder");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(id);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setCreateTime(LocalDateTime.now());
save(voucherOrder);
return Result.ok(id);
}
}
解决的问题
如果将代码修改成这样,在方法中加入synchronized(){}同步代码块,而为不同的用户使用不同的锁,这样就能保证同一用户在同一锁下,线程串行执行,防止并发安全问题使得一个用户购买多张券。
存在的问题
事务与锁的范围不一致,可能导致synchronized同步代码块执行完毕后,锁被释放出,但是事务要等方法执行完之后提交,此时其他线程进入就会造成数据不一致的并发安全问题,仍然有可能造成同一个用户购买到多张券的情况。
进一步优化代码
为了避免事务提交在锁释放之前执行,我们要扩大同步代码块的范围,因此我们可以将整个方法包围在synchronized同步代码块中,并且我们之前说到过我们不能将锁直接加到方法上造成所有线程串行执行,降低系统性能。综上所述,我们可以在调用方法时,使用同步代码块包围。
代码实现
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
RedisWorker redisWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 查优惠卷信息
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
if (seckillVoucher == null) {
return Result.fail("优惠卷不存在");
}
// 判断秒杀时间
LocalDateTime now = LocalDateTime.now();
if(seckillVoucher.getBeginTime().isAfter(now)){
return Result.fail("秒杀尚未开始");
}
if(seckillVoucher.getEndTime().isBefore(now)){
return Result.fail("秒杀已经结束");
}
// 查询库存
if(seckillVoucher.getStock()<0){
return Result.fail("库存不足");
}
// 提取一人一单,扣减库存,创建订单的代码加锁
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, userId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId, Long userId) {
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("已经购买过了");
}
// 扣减库存
boolean isSuccess = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0).update();
if (!isSuccess) {
return Result.fail("库存不足");
}
// 创建订单
long id = redisWorker.nextId("seckillVoucherOrder");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(id);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setCreateTime(LocalDateTime.now());
save(voucherOrder);
return Result.ok(id);
}
}