Redisson分布式锁学习
之前工作中一直使用redis来实现分布式锁,但是最近项目使用了云弹性,机器会涉及到扩缩容,涉及到优雅停机的问题,普通的redis分布锁,一般使用时会设置锁的时间,但是如果在加锁期间 JVM异常重启等发生会导致分布式锁得不到及时释放,即使机器重启,还是获取不到分布式锁。因此决定使用一下Redisson来解决这个问题。
基于redis实现的分布式锁
加锁代码如下:
public boolean tryGlobalLock(String key, Integer expireSeconds) {Long resultLong = new Executor<Long>() {@Overridepublic Long executor(String key, JedisCluster jedisCluster) {String status = jedisCluster.set(key, GLOBAL_LOCK_VALUE, SetParams.setParams().nx().ex(expireSeconds == null ? DEFAULT_LOCK_EXPIRE_TIME : expireSeconds));if ("OK".equalsIgnoreCase(status)) {// 第一次设置,设置成功return 1L;} else {// 已经存在这个keyreturn 0L;}}}.run(key);return resultLong == 1L;
}
一般使用流程如下:
// 尝试获取分布式锁// 如果获取失败 则直接返回// 如果获取成功// 执行业务逻辑// 业务逻辑执行成功 要释放锁// 业务逻辑执行失败 要释放锁
如果在执行业务逻辑过程中 机器重启 优雅停机处理不合理 则会导致分布式锁不能及时释放,机器重启后,分布式锁仍获取不到,需要等待锁过期失效。
基于redisson实现的分布式锁
引入依赖
<dependency><groupId>org.redisson</groupId><artifactId>redisson</artifactId><version>3.17.5</version></dependency>
锁配置
@Configuration
@Slf4j
public class RedissonConfig {private String nodesString = "";private String password = "";@Beanpublic Redisson redisson() {// 这里连接串是使用 逗号拼接的所以手动分隔一下String[] nodeArray = nodesString.split(",");Config config = new Config();// 使用redis集群配置ClusterServersConfig clusterServersConfig = config.useClusterServers();for (String node : nodeArray) {clusterServersConfig.addNodeAddress("redis://"+node);}try {clusterServersConfig.setPassword(password);} catch (Exception exception) {log.error("init redisson fail ",exception);}return (Redisson) Redisson.create(config);}
}
redisson分布式锁的使用很简单
@Autowiredprivate Redisson redisson;// 获取锁对象RLock lock = redisson.getLock(lockName);logger.info("try get lock start>>>> key = {} currentThread = {}", messageManagerVO.getMsgType(), Thread.currentThread().getName());try {// 在指定时间范围内尝试加锁boolean flag = lock.tryLock(tryGetSeconds, TimeUnit.SECONDS);logger.info("try get lock end>>>> key = {} flag = {} currentThread = {}", messageManagerVO.getMsgType(), flag, Thread.currentThread().getName());if (flag) {// 模拟事务处理逻辑Thread.sleep(doBizSeconds * 1000 * 60);// 释放锁lock.unlock();logger.info("try release lock end>>>> key = {}, currentThread = {}", messageManagerVO.getMsgType(), Thread.currentThread().getName());}} catch (InterruptedException e) {logger.info("RedissonService tryGlobalLock exception", e);}
getLock方法获取锁对象
tryLock方法尝试加锁 不需要配置锁过期时间 没有执行unlock方法之前 锁会自动续约 如果线程中断 则锁会自动释放
unlock 释放锁
加锁是以当前线程来加锁的,一但当前线程获取到 则其他线程不能获取锁。
redisson源码简读
加锁逻辑
tryLock方法依次进入
public boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException {return this.tryLock(waitTime, -1L, unit);
}
首先查看正常获取锁的逻辑
long time = unit.toMillis(waitTime);long current = System.currentTimeMillis();long threadId = Thread.currentThread().getId();Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);if (ttl == null) {return true;}
核心方法 tryAcquireAsync
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {RFuture ttlRemainingFuture;if (leaseTime > 0L) {ttlRemainingFuture = this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);} else {ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);}CompletionStage<Long> f = ttlRemainingFuture.thenApply((ttlRemaining) -> {if (ttlRemaining == null) {if (leaseTime > 0L) {this.internalLockLeaseTime = unit.toMillis(leaseTime);} else {this.scheduleExpirationRenewal(threadId);}}return ttlRemaining;});return new CompletableFutureWrapper(f);}
其中方法
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {return this.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, command, "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);", Collections.singletonList(this.getRawName()), new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId)});}
可以看到实际上是异步执行一个redis lua脚本(Lua脚本是redis已经内置的一种轻量小巧语言,其执行是通过redis的eval /evalsha 命令来运行,把操作封装成一个Lua脚本,如论如何都是一次执行的原子操作)
if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end;
return redis.call('pttl', KEYS[1]);
其中脚本中涉及redis基本命令如下:
EXISTS 命令用于检查给定 key 是否存在 若 key 存在返回 1 ,否则返回 0;
Hincrby 命令用于为哈希表中的字段值加上指定增量值,如果哈希表的 key 不存在,一个新的哈希表被创建并执行 HINCRBY 命令。如果指定的字段不存在,那么在执行命令前,字段的值被初始化为 0;
PEXPIRE 命令以毫秒为单位设置 key 的生存时间 设置成功,返回 1 key 不存在或设置失败,返回 0;
Hexists 命令用于查看哈希表的指定字段是否存在 如果哈希表含有给定字段,返回 1 。 如果哈希表不含有给定字段,或 key 不存在,返回 0;
Pttl 命令以毫秒为单位返回 key 的剩余过期时间 当 key 不存在时,返回 -2 。 当 key 存在但没有设置剩余生存时间时,返回 -1 。 否则,以毫秒为单位,返回 key 的剩余生存时间;
参数含义如下:
KEYS保存分布式锁的名称
ARGV[1] 对应KEYS过期时间 默认为30s
ARGV[2] 对应线程ID
// 如果第一次加锁 则key不存在 则创建key hashmap 并将线程ID 放入map中 设置为1 设置过期时间
if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end;
// 如果key已经存在 并且map中含有线程ID 则将线程ID加一 实现可重入锁 设置过期时间
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end;
// 返回锁的剩余时间
return redis.call('pttl', KEYS[1]);
释放锁逻辑
unlock方法
public void unlock() {try {this.get(this.unlockAsync(Thread.currentThread().getId()));} catch (RedisException var2) {if (var2.getCause() instanceof IllegalMonitorStateException) {throw (IllegalMonitorStateException)var2.getCause();} else {throw var2;}}}
public RFuture<Void> unlockAsync(long threadId) {// 释放锁RFuture<Boolean> future = this.unlockInnerAsync(threadId);CompletionStage<Void> f = future.handle((opStatus, e) -> {// 取消锁的续约逻辑this.cancelExpirationRenewal(threadId);if (e != null) {throw new CompletionException(e);} else if (opStatus == null) {IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: " + this.id + " thread-id: " + threadId);throw new CompletionException(cause);} else {return null;}});return new CompletableFutureWrapper(f);}
主要包括释放锁和取消锁续约
释放锁执行lua脚本
protected RFuture<Boolean> unlockInnerAsync(long threadId) {return this.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0; else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end; return nil;", Arrays.asList(this.getRawName(), this.getChannelName()), new Object[]{LockPubSub.UNLOCK_MESSAGE, this.internalLockLeaseTime, this.getLockName(threadId)});}
// 如果线程ID在map中不存在 则直接返回nil
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;end;
// 如果线程ID在map中存在 则减一 返回当前对应的value值counter
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
// 如果counter大于0 表示可重入锁没有全部释放完 则续约
if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0;
else 如果 counter=0 表示锁已经不存在 则直接删除keyredis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end;
return nil;
锁续约逻辑
redisson中数据结构(map)如下:
lockName: 过期时间
线程ID 线程重入次数
由加锁逻辑可知 默认锁的过期时间为30s 后续会不断进行续约 保证锁不会释放
tryAcquireAsync方法中加锁之后会进行锁的续约
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {RFuture ttlRemainingFuture;if (leaseTime > 0L) {ttlRemainingFuture = this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);} else {ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);}CompletionStage<Long> f = ttlRemainingFuture.thenApply((ttlRemaining) -> {if (ttlRemaining == null) {if (leaseTime > 0L) {this.internalLockLeaseTime = unit.toMillis(leaseTime);} else {this.scheduleExpirationRenewal(threadId);}}return ttlRemaining;});return new CompletableFutureWrapper(f);}
进入方法scheduleExpirationRenewal
protected void scheduleExpirationRenewal(long threadId) {ExpirationEntry entry = new ExpirationEntry();ExpirationEntry oldEntry = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.putIfAbsent(this.getEntryName(), entry);if (oldEntry != null) {oldEntry.addThreadId(threadId);} else {entry.addThreadId(threadId);try {this.renewExpiration();} finally {if (Thread.currentThread().isInterrupted()) {this.cancelExpirationRenewal(threadId);}}}}
继续进入renewExpiration方法
private void renewExpiration() {ExpirationEntry ee = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());if (ee != null) {Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {public void run(Timeout timeout) throws Exception {ExpirationEntry ent = (ExpirationEntry)RedissonBaseLock.EXPIRATION_RENEWAL_MAP.get(RedissonBaseLock.this.getEntryName());if (ent != null) {Long threadId = ent.getFirstThreadId();if (threadId != null) {CompletionStage<Boolean> future = RedissonBaseLock.this.renewExpirationAsync(threadId);future.whenComplete((res, e) -> {if (e != null) {RedissonBaseLock.log.error("Can't update lock " + RedissonBaseLock.this.getRawName() + " expiration", e);RedissonBaseLock.EXPIRATION_RENEWAL_MAP.remove(RedissonBaseLock.this.getEntryName());} else {if (res) {RedissonBaseLock.this.renewExpiration();} else {RedissonBaseLock.this.cancelExpirationRenewal((Long)null);}}});}}}}, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS);ee.setTimeout(task);}}
锁续约的方法renewExpirationAsync
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {return this.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;", Collections.singletonList(this.getRawName()), this.internalLockLeaseTime, this.getLockName(threadId));}
LUA脚本
// 如果缓存中map含有当前线程ID 则重置缓存失效时间 默认30s
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]);return 1; end;
return 0;
取消锁续约逻辑
protected void cancelExpirationRenewal(Long threadId) {ExpirationEntry task = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());if (task != null) {if (threadId != null) {task.removeThreadId(threadId);}if (threadId == null || task.hasNoThreads()) {Timeout timeout = task.getTimeout();if (timeout != null) {timeout.cancel();}EXPIRATION_RENEWAL_MAP.remove(this.getEntryName());}}}
redisson围绕map 线程ID 重入次数 数据结构来通过lua脚本原子执行来保证分布式锁。其功能很强大 ,可以实现其他公平锁 读写锁等功能,后面可以深入了解一下。
参考资料
最强分布式锁工具:Redisson
https://github.com/redisson/redisson
相关文章:
Redisson分布式锁学习
之前工作中一直使用redis来实现分布式锁,但是最近项目使用了云弹性,机器会涉及到扩缩容,涉及到优雅停机的问题,普通的redis分布锁,一般使用时会设置锁的时间,但是如果在加锁期间 JVM异常重启等发生会导致分…...
Metabase:简单快捷的商业智能与数据分析工具 | 开源日报 No.61
moby/moby Stars: 66.8k License: Apache-2.0 Moby 是一个由 Docker 创建的开源项目,旨在实现和加速软件容器化。它提供了工具包组件的“乐高集”,可以将它们组装成基于容器的自定义系统的框架。组件包括容器生成工具、容器注册表、业务流程工具、运行时…...
【无标题】高流量大并发Linux TCP性能调优
最近在使用jmeter做压测,当jmeter的并发量高的时候发现jmeter服务器一直报错Cannot assign requested address, 查看了一下发现系统中存在大量处于TIME_WAIT状态的tcp端口 netstat -n | awk ‘/^tcp/ {S[$NF]} END {for(a in S) print a, S[a]}’ TIME_W…...
优雅的用户体验:微信小程序中的多步骤表单引导
前言 在微信小程序中,实现一个多步骤表单引导界面既可以提供清晰的任务指引,又可以增加用户体验的互动性。本文将探讨如何使用微信小程序的特性,构建一个流程引导界面,帮助用户一步步完成复杂任务。我们将从设计布局和样式开始&am…...
Kotlin中的委托、属性委托和延迟加载
委托模式是一种常用的设计模式,用于将某个对象的责任委托给另一个对象来处理。在Kotlin中,委托可以通过关键字by来实现,主要分为类委托和属性委托两种形式。此外,Kotlin还提供了延迟加载的功能,可以在需要时才进行初始…...
轻松合并Excel工作表:Java批量操作优化技巧
摘要:本文由葡萄城技术团队于CSDN原创并首发。转载请注明出处:葡萄城官网,葡萄城为开发者提供专业的开发工具、解决方案和服务,赋能开发者。 前言 在Excel中设计表单时,我们经常需要对收集的信息进行统计分析。例如&a…...
计算机网络_网络层概述
4.1 网络层概述 4.1.1 一.分组转发和路由选择 网络层的主要任务就是将分组从源主机经过多个网络和多段链路传输到目的主机,可以将该任务划分为分组转发和路由选择两种重要的功能。 注释:A发送到B,从1端口进入. 如何得知是从2还是从3中转发出去呢?--------->这…...
自然语言处理---Transformer机制详解之GPT2模型介绍
1 GPT2的架构 从模型架构上看, GPT2并没有特别新颖的架构, 它和只带有解码器模块的Transformer很像. 所谓语言模型, 作用就是根据已有句子的一部分, 来预测下一个单词会是什么. 现实应用中大家最熟悉的一个语言模型应用, 就是智能手机上的输入法, 它可以根据当前输入的内容智…...
ChatGPT 即将诞生一周年,OpenAI 将有大动作
图片来源:由无界AI生成 下个月就是 ChatGPT 一周年纪念日。OpenAI 正在谋划新的大动作。可以肯定地说,自诞生以来,ChatGPT 就为 OpenAI 提供了不可阻挡的增长动力。 01 营收超预期,OpenAI 缓了一口气 据 The Information 报道&…...
jenkins 原理篇——pipeline流水线 声明式语法详解
大家好,我是蓝胖子,相信大家平时项目中或多或少都有用到jenkins,它的piepeline模式能够对项目的发布流程进行编排,优化部署效率,减少错误的发生,如何去写一个pipeline脚本呢,今天我们就来简单看…...
在ESP32-Arduino开发中添加其它Arduino库
目录 前言 原理说明 操作步骤 下载Bounce 安装Bounce 将下载的文件夹(压缩包需要解压)移动到components/arduino/libraries路径下,并重命名为Bounce2 查看添加库里所有的源文件位置 在arduino的CMakeList.txt里添加库源文件 使用Bounce 前言 乐鑫官方的es…...
CAN总线测试——CAN一致性之物理层
CAN一致性物理层测试项 1.最小通讯电压测试2.最大通讯电压测试3.显性位/隐性位输出电压测试4.信号跳变沿测试5. 地偏移6. 终端电阻 1.最小通讯电压测试 2.最大通讯电压测试 3.显性位/隐性位输出电压测试 4.信号跳变沿测试 5. 地偏移 6. 终端电阻...
macrodata数据集在Python统计建模和计量经济学中的应用
目录 一、数据介绍二、应用三、statsmodels 统计模块四、使用 statsmodels 统计模块分析 macrodata.csv 数据集参考 一、数据介绍 macrodata.csv是一个示例数据集,通常用于统计分析和计量经济学中的教育和训练目的。这个数据集通常包括以下列: year&am…...
【C++进阶(九)】C++多态深度剖析
💓博主CSDN主页:杭电码农-NEO💓 ⏩专栏分类:C从入门到精通⏪ 🚚代码仓库:NEO的学习日记🚚 🌹关注我🫵带你学习C 🔝🔝 多态 1. 前言2. 多态的概念以及定义3. 多态的实…...
第二节——Vue 基本介绍
一、MV*的理解 1、概念 在计算机编程领域,MV*(也称为MVC、MVP、MVVM等)是一种用于组织和设计应用程序结构的模式。这些模式旨在实现应用程序的解耦、可维护性和可扩展性。MV代表着Model-View-(表示控制器或视图模型等其他组件&a…...
基于ResNet34的花朵分类
一.数据集准备 新建一个项目文件夹ResNet,并在里面建立data_set文件夹用来保存数据集,在data_set文件夹下创建新文件夹"flower_data",点击链接下载花分类数据集https://storage.googleapis.com/download.tensorflow.org/example_i…...
[计算机提升] 数据及相关概念
1.9 数据及相关概念 1.9.1 数据、信息 在Windows系统中,数据是指事实或信息的集合,可以是数字、文本、图像、声音等形式的内容。数据是计算机系统中处理和操作的基本元素,是信息的表现形式和载体。 与信息相比,数据的范围更广泛…...
第18章 SpringCloud生态(二)
18.11 说说你了解的负载均衡算法 难度:★★ 重点:★★★★ 白话解析 常用的负载均衡算法有: 1、轮询(Round Robin):说白了就是让服务器排好队,一个个轮着来调用;Ribbon默认采用该算法。 优点:实现起来简单; 缺点:服务器性能不一样的情况下,导致能力强的会经常空闲…...
【Android】BRVAH多布局实现
前言 基于3.0.4版本的BRVAH框架实现的 实现方法 1.创建多个不同类型的布局(步骤忽略) 2.创建数据实体类 数据类要实现【MultiItemEntity】接口 class MyMultiItemEntity(//获取布局类型override var itemType: Int,var tractorRes: Int? null,va…...
AWS SAP-C02教程9-节省成本
SAP-C01变成SAP-C02的时候,最大的变化就是没有把成本单独列出一个模块,但是成本依然包含在各个其它模块之中,所以成本还是很重要的。本章将列举一些成本优化方案以及一些成本辅助功能。 目录 1 Cost Allocation Tags2 Trusted Advisor2.1 AWS Support Plans2.2 基本特性2.3…...
第19节 Node.js Express 框架
Express 是一个为Node.js设计的web开发框架,它基于nodejs平台。 Express 简介 Express是一个简洁而灵活的node.js Web应用框架, 提供了一系列强大特性帮助你创建各种Web应用,和丰富的HTTP工具。 使用Express可以快速地搭建一个完整功能的网站。 Expre…...
web vue 项目 Docker化部署
Web 项目 Docker 化部署详细教程 目录 Web 项目 Docker 化部署概述Dockerfile 详解 构建阶段生产阶段 构建和运行 Docker 镜像 1. Web 项目 Docker 化部署概述 Docker 化部署的主要步骤分为以下几个阶段: 构建阶段(Build Stage):…...
linux之kylin系统nginx的安装
一、nginx的作用 1.可做高性能的web服务器 直接处理静态资源(HTML/CSS/图片等),响应速度远超传统服务器类似apache支持高并发连接 2.反向代理服务器 隐藏后端服务器IP地址,提高安全性 3.负载均衡服务器 支持多种策略分发流量…...
反射获取方法和属性
Java反射获取方法 在Java中,反射(Reflection)是一种强大的机制,允许程序在运行时访问和操作类的内部属性和方法。通过反射,可以动态地创建对象、调用方法、改变属性值,这在很多Java框架中如Spring和Hiberna…...
ardupilot 开发环境eclipse 中import 缺少C++
目录 文章目录 目录摘要1.修复过程摘要 本节主要解决ardupilot 开发环境eclipse 中import 缺少C++,无法导入ardupilot代码,会引起查看不方便的问题。如下图所示 1.修复过程 0.安装ubuntu 软件中自带的eclipse 1.打开eclipse—Help—install new software 2.在 Work with中…...
工业自动化时代的精准装配革新:迁移科技3D视觉系统如何重塑机器人定位装配
AI3D视觉的工业赋能者 迁移科技成立于2017年,作为行业领先的3D工业相机及视觉系统供应商,累计完成数亿元融资。其核心技术覆盖硬件设计、算法优化及软件集成,通过稳定、易用、高回报的AI3D视觉系统,为汽车、新能源、金属制造等行…...
无人机侦测与反制技术的进展与应用
国家电网无人机侦测与反制技术的进展与应用 引言 随着无人机(无人驾驶飞行器,UAV)技术的快速发展,其在商业、娱乐和军事领域的广泛应用带来了新的安全挑战。特别是对于关键基础设施如电力系统,无人机的“黑飞”&…...
从 GreenPlum 到镜舟数据库:杭银消费金融湖仓一体转型实践
作者:吴岐诗,杭银消费金融大数据应用开发工程师 本文整理自杭银消费金融大数据应用开发工程师在StarRocks Summit Asia 2024的分享 引言:融合数据湖与数仓的创新之路 在数字金融时代,数据已成为金融机构的核心竞争力。杭银消费金…...
基于Java+VUE+MariaDB实现(Web)仿小米商城
仿小米商城 环境安装 nodejs maven JDK11 运行 mvn clean install -DskipTestscd adminmvn spring-boot:runcd ../webmvn spring-boot:runcd ../xiaomi-store-admin-vuenpm installnpm run servecd ../xiaomi-store-vuenpm installnpm run serve 注意:运行前…...
数据结构第5章:树和二叉树完全指南(自整理详细图文笔记)
名人说:莫道桑榆晚,为霞尚满天。——刘禹锡(刘梦得,诗豪) 原创笔记:Code_流苏(CSDN)(一个喜欢古诗词和编程的Coder😊) 上一篇:《数据结构第4章 数组和广义表》…...
