当前位置: 首页 > article >正文

经验分享:用一张表解决并发冲突!数据库事务锁的核心实现逻辑

背景

对于一些内部使用的管理系统来说,可能没有引入Redis,又想基于现有的基础设施处理并发问题,而数据库是每个应用都避不开的基础设施之一,因此分享个我曾经维护过的一个系统中,使用数据库表来实现事务锁的方式。

之前在文章Java业务功能并发问题处理中实现了使用MySQL行锁、Redis分布式锁来处理业务并发问题,这次来填坑了,如果想了解其他并发问题处理方式和区别,可以看看文章Java业务功能并发问题处理哈。

业务流程说明

业务加锁流程图

方案分析

适用场景

  1. 应用服务有多个实例,但是数据库是单实例;
  2. 没有用上Redis的应用服务,想通过现有的基础设施解决并发数据问题

待改进措施

  1. 设置超时机制:当出现锁无法及时释放时需要手动删除表数据,可以设置逻辑删除字段或者定时器删除过期数据;
  2. 重试获取锁机制:设置一定的循环次数,当获取不到锁时休眠200毫秒再次获取,直到循环次数用尽后再返回失败;
  3. 锁重入支持:通过增加加锁次数字段让当同一个线程可以重复获取锁

程序实现过程

框架及工具说明

  • 技术框架:SpringBootMyBatisMaven
  • 数据库:MySQL
  • 测试工具:Apifox
  • 表设计及代码说明:
    1. 唯一索引:需要有一个用于判断唯一的字段,在数据库表中通过指定唯一索引来实现;
    2. 加锁的线程号:避免A线程加的锁,被B线程删除;
    3. 锁的可见性要单独事务:添加事务锁的逻辑应在我们执行业务逻辑的事务之前,且不能跟业务逻辑的事务在一块,否则在事务提交前其他线程根本看不到这个锁,也就达不到我们锁的目的了;
    4. 为了我们的锁更方便使用,也可以将加锁逻辑抽到注解中实现,注解的实现流程:
      • 在pom文件中引入spring-boot-starter-aop
      • 编写自定义注解ConcurrencyLock
      • 实现切面类(Aspect)逻辑

代码展示

为了能让大家更关注加解锁逻辑,本文只保留主要代码,参考链接处会放置码云(gitee)的源码地址(或者点击此处跳转);
另外,本文就不展示注解方式的使用了,以免占用篇幅。

代码结构图

代码结构图

实体类

/*** 并发锁实体类*/
public class ConcurrencyLockBean {/*** 数据库主键*/private Long id;/*** 操作节点*/private String businessNode;/*** 订单唯一编号*/private String businessUniqueNo;/*** 线程ID*/private Long threadId;/*** 创建日期*/private Date creationDate;
}/*** 订单实体类*/
@Setter
@Getter
@ToString
public class OrderInfoBean {/*** 自增长主键*/private int id;/*** 订单号*/private String orderNo;/*** 物料数量*/private Integer itemQty;
}

ConcurrencyLockServiceImpl.java

@Slf4j
@Service
public class ConcurrencyLockServiceImpl implements ConcurrencyLockService {ConcurrencyLockMapper mapper;/*** service类注入*/@AutowiredConcurrencyLockServiceImpl(ConcurrencyLockMapper mapper) {this.mapper = mapper;}@Overridepublic Boolean tryLock(String businessNode, String businessUniqueNo) {long threadId = Thread.currentThread().getId();ConcurrencyLockBean concurrencyLock = mapper.selectConcurrencyLock(businessNode, businessUniqueNo);if (concurrencyLock != null) {log.info("{}数据正在操作中,请稍后", threadId);return false;}ConcurrencyLockBean lock = new ConcurrencyLockBean();lock.setBusinessNode(businessNode);lock.setBusinessUniqueNo(businessUniqueNo);lock.setThreadId(threadId);try {int insertCount = mapper.insertConcurrencyLock(lock);if (insertCount == 0) {log.info("{}获取锁失败,请稍后重试", threadId);return false;}} catch (Exception e) {log.info("{}获取锁异常,请稍后重试", threadId);return false;}log.info("{}完成锁表插入", threadId);return true;}@Overridepublic void unLock(String businessNode, String businessUniqueNo) {ConcurrencyLockBean lock = new ConcurrencyLockBean();long threadId = Thread.currentThread().getId();lock.setThreadId(threadId);lock.setBusinessNode(businessNode);lock.setBusinessUniqueNo(businessUniqueNo);mapper.deleteConcurrencyLock(lock);log.info("{}执行解锁完毕", threadId);}
}

ConcurrencyLockMapper.java

import org.apache.ibatis.annotations.Param;public interface ConcurrencyLockMapper {/*** 根据业务节点和唯一业务号查询锁*/ConcurrencyLockBean selectConcurrencyLock(@Param("businessNode") String businessNode, @Param("businessUniqueNo") String businessUniqueNo);/*** 插入锁*/int insertConcurrencyLock(ConcurrencyLockBean lock);/*** 删除锁*/int deleteConcurrencyLock(ConcurrencyLockBean lock);
}

ConcurrencyLockMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.runningrookie.mapper.ConcurrencyLockMapper"><select id="selectConcurrencyLock" resultType="com.runningrookie.domain.ConcurrencyLockBean">SELECTTHREAD_ID,BUSINESS_NODE,BUSINESS_UNIQUE_NO,CREATION_DATEFROM concurrency_lockWHERE BUSINESS_UNIQUE_NO = #{businessUniqueNo}AND BUSINESS_NODE = #{businessNode}</select><insert id="insertConcurrencyLock" useGeneratedKeys="true" keyProperty="id">INSERT INTO concurrency_lock (THREAD_ID,BUSINESS_NODE,BUSINESS_UNIQUE_NO,CREATION_DATE)VALUES(#{threadId}, #{businessNode}, #{businessUniqueNo}, NOW());</insert><delete id="deleteConcurrencyLock">DELETE FROM concurrency_lockWHERE THREAD_ID = #{threadId}and BUSINESS_NODE = #{businessNode}and BUSINESS_UNIQUE_NO = #{businessUniqueNo}</delete>
</mapper>

ConcurrencyLock.java注解

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ConcurrencyLock {String businessNode();String businessUniqueNoKey();
}

ConcurrencyLockAspect.java注解类

@Aspect
@Component
@Slf4j
public class ConcurrencyLockAspect {ConcurrencyLockService concurrencyLockService;@AutowiredConcurrencyLockAspect(ConcurrencyLockService concurrencyLockService) {this.concurrencyLockService = concurrencyLockService;}// 环绕切面@Around("@annotation(concurrencyLock)")public Object around(ProceedingJoinPoint joinPoint, ConcurrencyLock concurrencyLock) throws Throwable {long threadId = Thread.currentThread().getId();Object[] args = joinPoint.getArgs();if (args.length == 0) {return joinPoint.proceed();}// 通过反射获取值String invokeMethodName = "get" + concurrencyLock.businessUniqueNoKey().substring(0, 1).toUpperCase() + concurrencyLock.businessUniqueNoKey().substring(1);// 获取Order类的Class对象Class<?> clazz = args[0].getClass();// 获取getOrderNo方法的Method对象Method method = clazz.getMethod(invokeMethodName);// 调用getOrderNo方法并获取返回值String businessUniqueNo = method.invoke(args[0]).toString();Boolean isSuccessLock = concurrencyLockService.tryLock(concurrencyLock.businessNode(), businessUniqueNo);if (!isSuccessLock) {log.info("{}加锁失败,请稍后重试", threadId);// 生成与切点方法相同的返回对象return AjaxResult.error("加锁失败,请稍后重试");}try {log.info("{}开始执行业务逻辑", threadId);joinPoint.proceed();} finally {concurrencyLockService.unLock(concurrencyLock.businessNode(), businessUniqueNo);}return joinPoint.proceed();}
}

OrderInfoController.java

@RestController
@RequestMapping("/orderInfo")
public class OrderInfoController {OrderInfoService orderInfoService;@Autowiredprivate OrderInfoController(OrderInfoService orderInfoService) {this.orderInfoService = orderInfoService;}@PostMappingpublic AjaxResult saveOrderInfo(@RequestBody OrderInfoBean bean) {return orderInfoService.saveOrderInfo(bean);}
}

OrderServiceImpl.java

/*** 订单逻辑代码*/
@Slf4j
@Service
public class OrderInfoServiceImpl implements OrderInfoService {ConcurrencyLockService concurrencyLockService;/*** service类注入*/@AutowiredOrderInfoServiceImpl(ConcurrencyLockService concurrencyLockService) {this.concurrencyLockService = concurrencyLockService;}@Overridepublic AjaxResult saveOrderInfo(OrderInfoBean bean) {long threadId = Thread.currentThread().getId();final String businessNode = "插入";Boolean isSuccessLock = concurrencyLockService.tryLock(businessNode, bean.getOrderNo());if (!isSuccessLock) {return AjaxResult.error("加锁失败,请稍后重试");}try {log.info("{}开始执行业务逻辑", threadId);// TODO:模拟业务逻辑耗时Thread.sleep(1500);} catch (InterruptedException e) {throw new RuntimeException(e);} finally {concurrencyLockService.unLock(businessNode, bean.getOrderNo());}return AjaxResult.success();}@Override@ConcurrencyLock(businessNode = "插入", businessUniqueNoKey = "orderNo")@Transactionalpublic AjaxResult saveOrderInfoByAnnotation(OrderInfoBean bean) {// TODO:模拟业务逻辑耗时Thread.sleep(1500);return AjaxResult.success();}
}

pom.xml相关依赖

dependencies中添加下列依赖

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.1.4</version>
</dependency>
<!-- Mysql驱动包 -->
<dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId>
</dependency>

事务处理表的表结构

CREATE TABLE `concurrency_lock` (`ID` int NOT NULL AUTO_INCREMENT COMMENT '主键',`THREAD_ID` int DEFAULT NULL COMMENT '线程号',`BUSINESS_NODE` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '操作节点',`BUSINESS_UNIQUE_NO` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '单据号',`CREATION_DATE` datetime DEFAULT NULL COMMENT '创建时间',PRIMARY KEY (`ID`),UNIQUE KEY `uni_business_no` (`BUSINESS_UNIQUE_NO`,`BUSINESS_NODE`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

测试输出结果

使用Apifox并发发送5次请求,可以看到实际成功获取到锁并执行的只有一个线程

17:08:00.449 [http-nio-8080-exec-1] c.r.service.impl.ConcurrencyLockServiceImpl - 40完成锁表插入
17:08:00.462 [http-nio-8080-exec-1] c.runningrookie.service.impl.OrderInfoServiceImpl - 40开始执行业务逻辑
17:08:00.573 [http-nio-8080-exec-5] c.r.service.impl.ConcurrencyLockServiceImpl - 44获取锁异常,请稍后重试
17:08:00.573 [http-nio-8080-exec-4] c.r.service.impl.ConcurrencyLockServiceImpl - 43获取锁异常,请稍后重试
17:08:00.573 [http-nio-8080-exec-3] c.r.service.impl.ConcurrencyLockServiceImpl - 42获取锁异常,请稍后重试
17:08:00.573 [http-nio-8080-exec-2] c.r.service.impl.ConcurrencyLockServiceImpl - 41获取锁异常,请稍后重试
17:08:00.574 [http-nio-8080-exec-1] c.r.service.impl.ConcurrencyLockServiceImpl - 40执行解锁完毕

参考链接

gitee代码仓库地址:数据库并发锁

相关文章:

经验分享:用一张表解决并发冲突!数据库事务锁的核心实现逻辑

背景 对于一些内部使用的管理系统来说&#xff0c;可能没有引入Redis&#xff0c;又想基于现有的基础设施处理并发问题&#xff0c;而数据库是每个应用都避不开的基础设施之一&#xff0c;因此分享个我曾经维护过的一个系统中&#xff0c;使用数据库表来实现事务锁的方式。 之…...

C#项目文件.csproj 文件结构解析

以下是对提供的 .csproj 文件内容的详细解析&#xff1a; 1. ‌项目根元素‌ <Project ToolsVersion"12.0" DefaultTargets"Build" xmlns"http://schemas.microsoft.com/developer/msbuild/2003"> ToolsVersion"12.0": 指定使…...

C++-第二十章:智能指针

目录 第一节&#xff1a;std::auto_ptr 第二节&#xff1a;std::unique_ptr 第三节&#xff1a;std::shared_ptr 第四节&#xff1a;std::shared_ptr的缺陷 4-1.循环引用 4-2.删除器 下期预告&#xff1a; 智能指针的作用是防止指针出作用域时忘记释放内存而造成内存泄漏&…...

chrome Vue.js devtools 提示不支持该扩展组件,移除

可能是版本不兼容&#xff0c;可以重新安装&#xff0c;推荐网址极简插件官网_Chrome插件下载_Chrome浏览器应用商店 直接搜索vue&#xff0c;下载旧版&#xff0c;vue2、vue3都支持&#xff0c;上面那个最新版本试了下&#xff0c;vue2的肯定是不能用...

C# 中的Action和Func是什么?Unity 中的UnityAction是什么? 他们有什么区别?

所属范围&#xff1a;Action 和 Func 是 C# 语言标准库中的委托类型&#xff0c;可在任何 C# 项目里使用&#xff1b;UnityAction 是 Unity 引擎专门定义的委托类型&#xff0c;只能在 Unity 项目中使用。 返回值&#xff1a;Action 和 UnityAction 封装的方法没有返回值&…...

【流行病学】Melodi-Presto因果关联工具

title: “[流行病学] Melodi Presto因果关联工具” date: 2022-12-08 lastmod: 2022-12-08 draft: false tags: [“流行病学”,“因果关联工具”] toc: true autoCollapseToc: true 阅读介绍 Melodi-Presto: A fast and agile tool to explore semantic triples derived from …...

Stream在Swift 和 Flutter上的对比

Swift 和 Flutter 都是跨平台开发框架&#xff0c;它们各自提供了强大的工具来处理数据流&#xff0c;尤其是在移动应用开发中。虽然 Swift 主要用于 iOS 开发&#xff0c;而 Flutter 主要用于移动应用的开发&#xff08;包括 iOS 和 Android&#xff09;&#xff0c;但它们各自…...

Vue3 TransitionGroup组件深入解析:结合Element Plus实践指南

引言 在动态列表交互场景中&#xff0c;元素的增删排序需要优雅的过渡效果。Vue3的TransitionGroup组件为这类需求提供了专业解决方案。本文将通过Element Plus等流行UI库的实战案例&#xff0c;深入剖析TransitionGroup的应用技巧。 一、TransitionGroup核心特性 1.1 与Tran…...

关于opencv中solvepnp中UPNP与DLS与EPNP的参数

The methods SOLVEPNP_DLS and SOLVEPNP_UPNP cannot be used as the current implementations are unstable and sometimes give completely wrong results. If you pass one of these two flags, SOLVEPNP_EPNP method will be used instead.、 由于当前的实现不稳定&#x…...

Versal - XRT(CPP) 2024.1

目录 1.简介 2. XRT 2.1 XRT vs OpenCL 2.2 Takeways 2.3 XRT C APIs 2.4 Device and XCLBIN 2.5 Buffers 2.5.1 Buffer 创建 2.5.1.1 普通 Buffer 2.5.1.2 特殊 Buffer 2.5.1.3 用户指针 Buffer 2.5.2 Data Transfer 2.5.2.1 read/write API 2.5.2.2 map API 2…...

【零基础到精通Java合集】第十八集:多线程与并发编程-线程池与Callable/Future应用

课程标题:线程池与Callable/Future应用(15分钟) 目标:掌握线程池的创建与管理,理解Callable任务与Future异步结果处理机制 0-1分钟:课程引入与线程池意义 以“银行窗口服务”类比线程池:复用固定资源(柜员)处理多任务(客户)。说明线程池的核心价值——避免频繁创建…...

windows下安装Open Web UI

windows下安装openwebui有三种方式,docker,pythonnode.js,整合包. 这里我选择的是第二种,非docker. 非Docker方式安装 1. 安装Python&#xff1a; 下载并安装Python 3.11&#xff0c;建议安装路径中不要包含中文字符&#xff0c;并勾选“Add python 3.11 to Path”选项。 安…...

【自用】NLP算法面经(4)

一、deepseek 1、MLA &#xff08;1&#xff09;LLM推理过程 prefill阶段&#xff1a;模型对全部的prompt tokens一次性并行计算&#xff0c;最终生成第一个输出token。decode阶段&#xff1a;每次生成一个token&#xff0c;直到生成EOS&#xff08;end-of-sequence&#xf…...

LeetCode热题100JS(20/100)第四天|​41. 缺失的第一个正数​|​73. 矩阵置零​|​54. 螺旋矩阵​|​48. 旋转图像​

41. 缺失的第一个正数 题目链接&#xff1a;41. 缺失的第一个正数 难度&#xff1a;困难 刷题状态&#xff1a;1刷 新知识&#xff1a; 解题过程 思考 示例 1&#xff1a; 输入&#xff1a;nums [1,2,0] 输出&#xff1a;3 解释&#xff1a;范围 [1,2] 中的数字都在数组中…...

【银河麒麟高级服务器操作系统实际案例分享】数据库资源重启现象分析及处理全过程

更多银河麒麟操作系统产品及技术讨论&#xff0c;欢迎加入银河麒麟操作系统官方论坛 https://forum.kylinos.cn 了解更多银河麒麟操作系统全新产品&#xff0c;请点击访问 麒麟软件产品专区&#xff1a;https://product.kylinos.cn 开发者专区&#xff1a;https://developer…...

开源架构与人工智能的融合:开启技术新纪元

最近五篇文章推荐&#xff1a; 开源架构的自动化测试策略优化版&#xff08;New&#xff09; 开源架构的容器化部署优化版&#xff08;New&#xff09; 开源架构的微服务架构实践优化版&#xff08;New&#xff09; 开源架构中的数据库选择优化版&#xff08;New&#xff09; 开…...

缓存那些事儿

为什么要使用缓存 性能 我们在碰到需要执行耗时特别久&#xff0c;且结果不频繁变动的SQL&#xff0c;就特别适合将运行结果放入缓存。这样&#xff0c;后面的请求就去缓存中读取&#xff0c;使得请求能够迅速响应。 并发 在大并发的情况下&#xff0c;所有的请求直接访问数…...

【弹性计算】弹性裸金属服务器和神龙虚拟化(二):适用场景

《弹性裸金属服务器》系列&#xff0c;共包含以下文章&#xff1a; 弹性裸金属服务器和神龙虚拟化&#xff08;一&#xff09;&#xff1a;功能特点弹性裸金属服务器和神龙虚拟化&#xff08;二&#xff09;&#xff1a;适用场景弹性裸金属服务器和神龙虚拟化&#xff08;三&a…...

通往 AI 之路:Python 机器学习入门-语法基础

第一章 Python 语法基础 Python 是一种简单易学的编程语言&#xff0c;广泛用于数据分析、机器学习和人工智能领域。在学习机器学习之前&#xff0c;我们需要先掌握 Python 的基本语法。本章将介绍 Python 的变量与数据类型、条件语句、循环、函数以及文件操作&#xff0c;帮助…...

FastGPT 引申:信息抽取到知识图谱的衔接流程

文章目录 信息抽取到知识图谱的衔接流程步骤1&#xff1a;原始信息抽取结果步骤2&#xff1a;数据标准化处理&#xff08;Python示例&#xff09;步骤3&#xff1a;Cypher代码动态生成&#xff08;Python驱动&#xff09; 关键衔接逻辑说明1. 唯一标识符生成规则2. 数据映射策略…...

计算机毕业设计SpringBoot+Vue.js保险合同管理系统(源码+文档+PPT+讲解)

温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 作者简介&#xff1a;Java领…...

58、深度学习-自学之路-自己搭建深度学习框架-19、RNN神经网络梯度消失和爆炸的原因(从公式推导方向来说明),通过RNN的前向传播和反向传播公式来理解。

一、RNN神经网络的前向传播图如下&#xff1a; 时间步 t1: x₁ → (W_x) → [RNN Cell] → h₁ → (W_y) → y₁ ↑ (W_h) h₀ (初始隐藏状态) 时间步 t2: x₂ → (W_x) → [RNN Cell] → h₂ → (W_y) → y₂ ↑ (W_h) h₁ 时间…...

什么是 Cholesky 分解?兼谈正定矩阵和二次型

Cholesky 分解在深度学习中的应用与理解 Cholesky 分解是一种用于对称正定矩阵的特殊分解方法&#xff0c;在线性代数和概率模型中有广泛应用。对于深度学习研究者来说&#xff0c;理解 Cholesky 分解不仅有助于掌握矩阵运算的理论基础&#xff0c;还能在高斯过程、变分方法&a…...

在单位,领导不说,但自己得懂的7个道理

如果你感到很难继续适应旧模式、旧关系、旧想法&#xff0c;开始厌倦生活&#xff0c;你很可能到了该蜕皮的时候。把“不是自己”的那部分舍弃掉&#xff0c;你就能看见“自己是谁”了。 ——奥赞瓦罗尔&#xff0c;《读者》2024年第11期 前几天听部门里一个新来的小姑娘抱怨&a…...

bge-large-zh-v1.5 与Pro/BAAI/bge-m3 区别

ge-large-zh-v1.5 和 Pro/BAAI/bge-m3 是两种不同的模型&#xff0c;主要区别在于架构、性能和应用场景。以下是它们的对比&#xff1a; 1. 模型架构 bge-large-zh-v1.5&#xff1a; 基于Transformer架构&#xff0c;专注于中文文本的嵌入表示。 参数量较大&#xff0c;适合处…...

lamp平台介绍

一、lamp介绍 网站&#xff1a; 静态 动态 php语言 .php 作用&#xff1a;运行php语言编写动态网站应用 lamp Linux Apache MySQL PHP PHP是作为httpd的一个功能模块存在的 二、部署lamp平台 1、测试httpd是否可正常返回PHP的响应 2、测试PHP代码是否可正常连接数据…...

Windows10系统构建本地安全私有化的个人知识库——采用DeepSeek+RAGFlow

一、为什么要构建本地私有化个人知识库 1.1、自身需求 1、需要相关隐私资料内容的安全保护可控; 2、需要根据自身的隐私资料内容构建出个性化的知识库; 一些常见的业务场景如:①希望我们的智能助手可以根据公司的管理制度回答问题,让员工可以随时了解公司相关制度内容信息;…...

Linux: Centos7 Cannot find a valid baseurl for repo: base/7/x86_64 解决方案

Linux: Centos7 Cannot find a valid baseurl for repo: base/7/x86_64 解决方案 问题背景&#xff1a; 执行yum update出现如下报错 排查虚拟机是否联网&#xff1a; ping -c 4 www.baidu.com 可以看到网络链接没有问题 解决方案&#xff1a; 原因是国外的镜像源有问题&am…...

Java 大视界 -- Java 大数据在智能金融反欺诈中的技术实现与案例分析(114)

&#x1f496;亲爱的朋友们&#xff0c;热烈欢迎来到 青云交的博客&#xff01;能与诸位在此相逢&#xff0c;我倍感荣幸。在这飞速更迭的时代&#xff0c;我们都渴望一方心灵净土&#xff0c;而 我的博客 正是这样温暖的所在。这里为你呈上趣味与实用兼具的知识&#xff0c;也…...

每日OJ_牛客_游游的字母串_枚举_C++_Java

目录 牛客_游游的字母串_枚举 题目解析 C代码 Java代码 牛客_游游的字母串_枚举 游游的字母串 描述&#xff1a; 对于一个小写字母而言&#xff0c;游游可以通过一次操作把这个字母变成相邻的字母。a和b相邻&#xff0c;b和c相邻&#xff0c;以此类推。特殊的&#xff0…...