业务幂等带来的性能上的损耗
交易系统讲究的是:业务幂等,不能多扣不能少扣。
比如说有一个用户他的帐户余额就10,000元,无论它进行了上万、上千次交易,每一笔交易成功的流水的状态、和金额必须要对的起来。说了简单点假设该帐户交易了100次,共计9万元的交易流水,但帐户余额就10,000,你不能给他记成负8万余额吧?那么我们来看这个100次交易流水。其中有30次交易成功,总额是10,000元扣款成功。其余70次都是交易失败。如果30次交易成功,总额是10,000元,然后如果出现了第31次也是交易成功,那么这第31次的交易成功怎么来的?这就是业务不幂等。
演示根据交易额扣款动作
根据业务需求与设计
我们下面可以来演示,不幂等时发生的代码问题
数据库表结构如下:
我们做一个spring boot的controller、service、dao,然后使用多个并发对这一个帐户里的余额进行操作。
每次操作,我们带着一个“扣除100元”的动作来访问这个帐号,根据传入的扣款来对帐户进行操作的业务代码如下:
AccountService.java
@Transactional(rollbackFor = Exception.class)
public ResponseBean updateBalance(AccountVO accountVO) throws Exception {
AccountVO resultAccount = new AccountVO();
try {
resultAccount = accountDao.selectBalance(accountVO);
if (resultAccount.getBalance() > 0) {// 如果余额为0,直接return出102
if (accountVO.getTransfMoney() <= resultAccount.getBalance()) {// 如果扣款>余额直接return 101,扣款失败
AccountVO updatedAccount = new AccountVO();
int balance = resultAccount.getBalance() - accountVO.getTransfMoney();
updatedAccount.setBalance(balance);
updatedAccount.setUid(accountVO.getUid());
accountDao.updateBalance(updatedAccount);
AccountVO returnAccount = accountDao.selectBalance(accountVO);
returnAccount.setTransfMoney(accountVO.getTransfMoney());
return new ResponseBean(0, "扣款成功", returnAccount);
} else {
return new ResponseBean(101, "扣款>余额");
}
} else {
return new ResponseBean(101, "余额为0");
}
} catch (Exception e) {
logger.error(">>>>>>updateBalance error: " + e.getMessage(), e);
throw new Exception(">>>>>>updateBalance error: " + e.getMessage(), e);
}
}
相应的mybatis的dao层代码如下
DemoAccountDao.xml
<select id="selectBalance"
parameterType="org.mk.demo.db.vo.AccountVO"
resultMap="accountResultMap">
SELECT uid,
balance
FROM account WHERE uid=#{uid}
</select>
<update id="updateBalance"
parameterType="org.mk.demo.db.vo.AccountVO">
UPDATE account
set balance=#{balance}
WHERE uid=#{uid}
</update>
相应的controller如下
private Logger logger = LoggerFactory.getLogger(this.getClass());
@Resource
private AccountService accountService;
@PostMapping(value = "/account/transfer", produces = "application/json")
@ResponseBody
public ResponseBean transfer(@RequestBody AccountVO account) {
logger.info(">>>>>>uid->" + account.getUid() + " transfMoney->" + account.getTransfMoney());
try {
return accountService.updateBalance(account);
} catch (Exception e) {
logger.error(">>>>>>transf error: " + e.getMessage(), e);
return new ResponseBean(-1, "system error");
}
}
看运行起来后使用30个并发来访问的效果
使用30个线程
运行后竟然有12次扣款成功,每次扣100元,帐户余额为1,000元。
?由于我们的数据库层做了如下超额、余额不能<0的基本逻辑判断,因此数据库里的值倒是对了。可是交易流水以及此时在前端用户端如:小程序或者是APP上的显示是错的。
if (resultAccount.getBalance() > 0) {// 如果余额为0,直接return出102
if (accountVO.getTransfMoney() <= resultAccount.getBalance()) {// 如果扣款>余额直接return 101,扣款失败
此时客户一定会产生“客诉”,我明明交易成功了,可是你后台告诉我其实是不成功?
你怎么去和你的客户解释呢?这就是业务不幂等!
为了交易流水、过程以及帐户余额幂等使用传统的“悲观”锁的下场
我们为了实现业务幂等会这么干
- 把jdbc设成setAutoCommit(false);
- 然后每次进入交易都去select 余额for update,如果在select x for update出错了认为没有抢到“锁”;
- 没有抢到“锁”的返回-请求排队中,抢到”锁“的返回-处理成功
- finally块中把setAutoCommit设回true;
对不对?99%的IT开发、传统软件觉得这么干,他们认为理所当然也事实上还都这么干了,然后它造成的后果是什么呢?
我把线程数改成了“并发线程”去跑这个加了锁的代码。
显示结果和数据库里的余额倒是幂等了,3分钟不到,应用已经卡死了。它的平均响应时间越来越慢,最后曾卡死不动状态。
如何又做到幂等又做到性能好呢-CAS及乐观锁的方案出现了
让我们来想一下需求:
在大并发的的互联网场景下需要保证进入支付通道的请求正常扣款,显示和流水以及余额要一致。未进入支付通道的请求直接返回”亲,排队中“。如果帐户余额已经用完了或者单笔交易额>余额,此时对于还在往系统内进入的支付请求直接返回”余额为0“。还要保证系统不卡死,不要保证系统的高并发。
于是我们使用如下的手法。
CAS及乐观锁设计手法
我们对这个原帐户表增加一个字段,叫version_no。version_no一开始全部为0.
交易时:
- 每次select时把for update语句去掉改成直接把version_no取出来带到交易业务方法内;
- 在update时,带入前面select出来的version_no,此时你的update sql会变成这样UPDATE account? set? balance=balance-#{transfMoney},version_no=version_no+1 WHERE uid=#{uid} and version_no=#{versionNo}。只要前一个version_no和本次带入的version_no不一致就代表产生了“竞争”,竞争就要“排除掉”,这相当于利用了数据库的特殊强制把并发的请求在DB内部改成了“串行”方式以保证业务的幂等;
- 根据update的useAffectedRows的返回即mysql的连接必须加上useAffectedRows=true的参数。如果update语句返回=1,代表进入支付通道并扣款成功。如果update语句返回=0,代表进入支付通道失败,返回:亲,排队中;
来看演示代码
AccountService
/**
* 0代表购物成功 101-代表购买的款项>帐户余额,不能购买 102-代表帐户余额为0,不能购买,103-代表动作太快了
*/
@Transactional(rollbackFor = Exception.class)
public ResponseBean updateBalanceWithOptimiLock(AccountVO accountVO) throws Exception {
AccountVO resultAccount = new AccountVO();
try {
resultAccount = accountDao.selectBalance(accountVO);
if (resultAccount.getBalance() > 0) {// 如果余额为0,直接return出102
if (accountVO.getTransfMoney() <= resultAccount.getBalance()) {// 如果扣款>余额直接return 101,扣款失败
// int balance = resultAccount.getBalance() - accountVO.getTransfMoney();
resultAccount.setTransfMoney(accountVO.getTransfMoney());
int affectedRows = accountDao.updateBalanceWithOptimiLock(resultAccount);
if (affectedRows > 0) {
logger.info(
">>>>>>uid->" + resultAccount.getUid() + " transfMoney->" + resultAccount.getTransfMoney()
+ " versionNo->" + resultAccount.getVersionNo() + " affectedRows->" + affectedRows);
AccountVO returnAccount = accountDao.selectBalance(accountVO);
returnAccount.setTransfMoney(accountVO.getTransfMoney());
return new ResponseBean(0, "扣款成功", returnAccount);
} else {
return new ResponseBean(103, "你的动作太快了,请稍侯");
}
} else {
return new ResponseBean(101, "扣款>余额");
}
} else {
return new ResponseBean(101, "余额为0");
}
} catch (Exception e) {
logger.error(">>>>>>updateBalance error: " + e.getMessage(), e);
throw new Exception(">>>>>>updateBalance error: " + e.getMessage(), e);
}
}
此处的“你的动作太快了,请稍侯“就是=“亲,排队中”。
然后对应的dao改造如下
<select id="selectBalance"
parameterType="org.mk.demo.db.vo.AccountVO"
resultMap="accountResultMap">
SELECT uid,
balance,
version_no
FROM account WHERE uid=#{uid}
</select>
<update id="updateBalanceWithOptimiLock"
parameterType="org.mk.demo.db.vo.AccountVO">
UPDATE account
set
balance=balance-#{transfMoney},version_no=version_no+1
WHERE uid=#{uid}
and
version_no=#{versionNo}
</update>
然后我们来看一下这个操作在并发的情况下效果如何。
30个并发下的效果演示
?设30个线程
运行后效果如下
看,只有2条请求进入了正常扣款,其它都显示为
去数据库里看一下几户余额
看,业务幂等了。
此时我们拿同样的上面的“并发线程”运行3分钟去测试它
客户端返回10条扣款请求成功,其余不是“的动作太快了,请稍侯“就是”余额为0或者是扣款>余额“,整个并发的过程共产生了9,300个请求。全测试过程顺利完成。
?再来看这个系统的性能
请注意以上第一个截图的”吞吐量“和后面的average response指标。这个系统的性能是完全可以适合互联网级别应用的。
对比使用Redis锁我们该怎么做呢
我们使用redission组件提供的“锁”。其实我们这把锁叫:分布式自动超时、自动续约锁,这把锁的好处在于:
- 永远是一把正向锁,每次要锁时会先探一下前面有没有锁了?如果有锁了我就不来锁你(新进程新指令就不会运行);
- ?如果前面没有锁(进程在运行)我再来锁你然后运行我的进程或者是指令;
- 前面一把锁如果碰到任何意外(包括了暴力挂机、杀进程),该锁也不会死在内存里而是在30秒后自动释放;
- 如果被锁住的数据处理时间假设需要60分钟已经远超了锁的时间30秒,那么该锁会在超时前10秒启动一个watchdog进程自动去向后台锁服务器申请+30秒时间以不断贴合着数据处理完的所用时间;
先来看pom.xml文件
<dependencies>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery
</artifactId>
</dependency>
<!-- redis must -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- jedis must -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!-- redission must -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>${redission.version}</version>
<!-- <exclusions> <exclusion> <groupId>org.redisson</groupId> <artifactId>redisson-spring-data-23</artifactId>
</exclusion> </exclusions> -->
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-data-21</artifactId>
<version>${redission.version}</version>
</dependency>
</dependencies>
?这边的redission.version我们用的版本号如下(切记),这个版本不会有redission在spring boot 工程启动时,时不时抛一个redission连接错误的bug。
<redission.version>3.16.1</redission.version>
?而我们的spring boot的version如下
<spring-boot.version>2.3.1.RELEASE</spring-boot.version>
?redission与spring boot的版本(version)必须完全对应,否则项目都启动不起来或者启动报错
RedissonProperties.java
package org.mk.demo.db.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
/**
*
* RedissonProperties
*
*
* Feb 20, 2021 1:56:09 PM
*
* @version 1.0.0
*
*/
@Configuration
@Component
public class RedissonProperties {
@Value("${spring.redis.timeout}")
private int timeout;
@Value("${spring.redis.password}")
private String password;
@Value("${spring.redis.database:0}")
private int database = 0;
@Value("${spring.redis.lettuce.pool.max-active:8}")
private int connectionPoolSize = 64;
@Value("${spring.redis.lettuce.pool.min-idle:0}")
private int connectionMinimumIdleSize = 10;
@Value("${spring.redis.lettuce.pool.max-active:8}")
private int slaveConnectionPoolSize = 250;
@Value("${spring.redis.lettuce.pool.max-active:8}")
private int masterConnectionPoolSize = 250;
@Value("${spring.redis.redisson.nodes}")
private String[] sentinelAddresses;
@Value("${spring.redis.sentinel.master}")
private String masterName;
public int getTimeout() {
return timeout;
}
public void setTimeout(int timeout) {
this.timeout = timeout;
}
public int getSlaveConnectionPoolSize() {
return slaveConnectionPoolSize;
}
public void setSlaveConnectionPoolSize(int slaveConnectionPoolSize) {
this.slaveConnectionPoolSize = slaveConnectionPoolSize;
}
public int getMasterConnectionPoolSize() {
return masterConnectionPoolSize;
}
public void setMasterConnectionPoolSize(int masterConnectionPoolSize) {
this.masterConnectionPoolSize = masterConnectionPoolSize;
}
public String[] getSentinelAddresses() {
return sentinelAddresses;
}
public void setSentinelAddresses(String sentinelAddresses) {
this.sentinelAddresses = sentinelAddresses.split(",");
}
public String getMasterName() {
return masterName;
}
public void setMasterName(String masterName) {
this.masterName = masterName;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public int getConnectionPoolSize() {
return connectionPoolSize;
}
public void setConnectionPoolSize(int connectionPoolSize) {
this.connectionPoolSize = connectionPoolSize;
}
public int getConnectionMinimumIdleSize() {
return connectionMinimumIdleSize;
}
public void setConnectionMinimumIdleSize(int connectionMinimumIdleSize) {
this.connectionMinimumIdleSize = connectionMinimumIdleSize;
}
public int getDatabase() {
return database;
}
public void setDatabase(int database) {
this.database = database;
}
public void setSentinelAddresses(String[] sentinelAddresses) {
this.sentinelAddresses = sentinelAddresses;
}
}
?RedisSentinelConfig.java文件-自动装配类
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import redis.clients.jedis.HostAndPort;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SentinelServersConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.*;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.Set;
import javax.annotation.Resource;
@Configuration
@EnableCaching
@Component
public class RedisSentinelConfig {
private Logger logger = LoggerFactory.getLogger(this.getClass());
@Resource
private RedissonProperties redssionProperties;
@Value("${spring.redis.nodes:localhost:7001}")
private String nodes;
@Value("${spring.redis.max-redirects:3}")
private Integer maxRedirects;
@Value("${spring.redis.password}")
private String password;
@Value("${spring.redis.database:0}")
private Integer database;
@Value("${spring.redis.timeout}")
private int timeout;
@Value("${spring.redis.sentinel.nodes}")
private String sentinel;
@Value("${spring.redis.lettuce.pool.max-active:8}")
private Integer maxActive;
@Value("${spring.redis.lettuce.pool.max-idle:8}")
private Integer maxIdle;
@Value("${spring.redis.lettuce.pool.max-wait:-1}")
private Long maxWait;
@Value("${spring.redis.lettuce.pool.min-idle:0}")
private Integer minIdle;
@Value("${spring.redis.sentinel.master}")
private String master;
@Value("${spring.redis.switchFlag}")
private String switchFlag;
@Value("${spring.redis.lettuce.pool.shutdown-timeout}")
private Integer shutdown;
@Value("${spring.redis.lettuce.pool.timeBetweenEvictionRunsMillis}")
private long timeBetweenEvictionRunsMillis;
public String getSwitchFlag() {
return switchFlag;
}
/**
* 连接池配置信息
*
* @return
*/
@Bean
public LettucePoolingClientConfiguration getPoolConfig() {
GenericObjectPoolConfig config = new GenericObjectPoolConfig();
config.setMaxTotal(maxActive);
config.setMaxWaitMillis(maxWait);
config.setMaxIdle(maxIdle);
config.setMinIdle(minIdle);
config.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis);
LettucePoolingClientConfiguration pool = LettucePoolingClientConfiguration.builder().poolConfig(config)
.commandTimeout(Duration.ofMillis(timeout)).shutdownTimeout(Duration.ofMillis(shutdown)).build();
return pool;
}
/**
* 配置 Redis Cluster 信息
*/
@Bean
@ConditionalOnMissingBean
public LettuceConnectionFactory lettuceConnectionFactory() {
LettuceConnectionFactory factory = null;
String[] split = nodes.split(",");
Set<HostAndPort> nodes = new LinkedHashSet<>();
for (int i = 0; i < split.length; i++) {
try {
String[] split1 = split[i].split(":");
nodes.add(new HostAndPort(split1[0], Integer.parseInt(split1[1])));
} catch (Exception e) {
logger.error(">>>>>>出现配置错误!请确认: " + e.getMessage(), e);
throw new RuntimeException(String.format("出现配置错误!请确认node=[%s]是否正确", nodes));
}
}
// 如果是哨兵的模式
if (!StringUtils.isEmpty(sentinel)) {
logger.info(">>>>>>Redis use SentinelConfiguration");
RedisSentinelConfiguration redisSentinelConfiguration = new RedisSentinelConfiguration();
String[] sentinelArray = sentinel.split(",");
for (String s : sentinelArray) {
try {
String[] split1 = s.split(":");
redisSentinelConfiguration.addSentinel(new RedisNode(split1[0], Integer.parseInt(split1[1])));
} catch (Exception e) {
logger.error(">>>>>>出现配置错误!请确认: " + e.getMessage(), e);
throw new RuntimeException(String.format("出现配置错误!请确认node=[%s]是否正确", sentinelArray));
}
}
redisSentinelConfiguration.setMaster(master);
redisSentinelConfiguration.setPassword(password);
factory = new LettuceConnectionFactory(redisSentinelConfiguration, getPoolConfig());
}
// 如果是单个节点 用Standalone模式
else {
if (nodes.size() < 2) {
logger.info(">>>>>>Redis use RedisStandaloneConfiguration");
for (HostAndPort n : nodes) {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
if (!StringUtils.isEmpty(password)) {
redisStandaloneConfiguration.setPassword(RedisPassword.of(password));
}
redisStandaloneConfiguration.setPort(n.getPort());
redisStandaloneConfiguration.setHostName(n.getHost());
factory = new LettuceConnectionFactory(redisStandaloneConfiguration, getPoolConfig());
}
} else {
logger.info(">>>>>>Redis use RedisClusterConfiguration");
RedisClusterConfiguration redisClusterConfiguration = new RedisClusterConfiguration();
nodes.forEach(n -> {
redisClusterConfiguration.addClusterNode(new RedisNode(n.getHost(), n.getPort()));
});
if (!StringUtils.isEmpty(password)) {
redisClusterConfiguration.setPassword(RedisPassword.of(password));
}
redisClusterConfiguration.setMaxRedirects(maxRedirects);
factory = new LettuceConnectionFactory(redisClusterConfiguration, getPoolConfig());
}
}
return factory;
}
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(lettuceConnectionFactory);
Jackson2JsonRedisSerializer jacksonSerial = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
// 指定要序列化的域,field,get和set,以及修饰符范围,ANY是都有包括private和public
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
jacksonSerial.setObjectMapper(om);
StringRedisSerializer stringSerial = new StringRedisSerializer();
template.setKeySerializer(stringSerial);
// template.setValueSerializer(stringSerial);
template.setValueSerializer(jacksonSerial);
template.setHashKeySerializer(stringSerial);
template.setHashValueSerializer(jacksonSerial);
template.afterPropertiesSet();
return template;
}
@Bean
RedissonClient redissonSentinel() {
logger.info(">>>>>>redisson address size->" + redssionProperties.getSentinelAddresses().length);
Config config = new Config();
SentinelServersConfig serverConfig =
config.useSentinelServers().addSentinelAddress(redssionProperties.getSentinelAddresses())
.setMasterName(redssionProperties.getMasterName()).setTimeout(redssionProperties.getTimeout())
.setMasterConnectionPoolSize(redssionProperties.getMasterConnectionPoolSize())
.setSlaveConnectionPoolSize(redssionProperties.getSlaveConnectionPoolSize());
if (StringUtils.isNotBlank(redssionProperties.getPassword())) {
serverConfig.setPassword(redssionProperties.getPassword());
}
return Redisson.create(config);
}
}
相应的yml文件内的配置
mysql:
datasource:
common:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.jdbc.Driver
minIdle: 25
initialSize: 5
maxActive: 25
maxWait: 5000
testOnBorrow: false
testOnReturn: false
testWhileIdle: true
validationQuery: select 1
timeBetweenEvictionRunsMillis: 300000
ConnectionErrorRetryAttempts: 3
NotFullTimeoutRetryCount: 3
minEvictableIdleTimeMillis: 60000
maxEvictableIdleTimeMillis: 300000
keepAliveBetweenTimeMillis: 480000
keepalive: true
master: #master db
url: jdbc:mysql://localhost:3306/demo_pay?useUnicode=true&characterEncoding=utf-8&useSSL=false&useAffectedRows=true&autoReconnect=true
username: root
password: 111111
slaver: #slaver db
url: jdbc:mysql://localhost:3307/demo_pay?useUnicode=true&characterEncoding=utf-8&useSSL=false&useAffectedRows=true&autoReconnect=true
username: root
password: 111111
server:
port: 9084
tomcat:
max-http-post-size: -1
#最小线程数
min-spare-threads: 150
#最大线程数
max-threads: 500
#最大链接数
max-connections: 1000
#最大等待队列长度
accept-count: 500
logging:
config: classpath:log4j2.xml
spring:
application:
name: db-demo
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
redis:
password: 111111
nodes: localhost:7001
redisson:
#nodes: redis://192.168.2.106:27001,redis://192.168.2.106:27002,redis://192.168.2.106:27003
nodes: redis://localhost:27001,redis://localhost:27002,redis://localhost:27003
sentinel:
#nodes:
#master:
#nodes: 192.168.2.106:27001,192.168.2.106:27002,192.168.2.106:27003
nodes: localhost:27001,localhost:27002,localhost:27003
master: master1
database: 0
switchFlag: 1
lettuce:
pool:
max-active: 50
max-wait: 10000
max-idle: 10
min-idl: 5
shutdown-timeout: 2000
timeBetweenEvictionRunsMillis: 5000
timeout: 5000
业务代码内的使用AccountService类
@Transactional(rollbackFor = Exception.class)
public ResponseBean updateBalanceWithRedissonLock(AccountVO accountVO) throws Exception {
AccountVO resultAccount = new AccountVO();
RLock lock = redissonSentinel.getLock(Constants.TRANSF_MONEY_LOCK);
try {
boolean islock = lock.tryLock(0, TimeUnit.SECONDS);
if (!islock) {
return new ResponseBean(103, "你的动作太快了,请稍侯");
} else {
resultAccount = accountDao.selectBalance(accountVO);
if (resultAccount.getBalance() > 0) {// 如果余额为0,直接return出102
if (accountVO.getTransfMoney() <= resultAccount.getBalance()) {// 如果扣款>余额直接return 101,扣款失败
int balance = resultAccount.getBalance() - accountVO.getTransfMoney();
resultAccount.setTransfMoney(accountVO.getTransfMoney());
// resultAccount.setBalance(balance);
int affectedRows = accountDao.updateBalanceWithRedissonLock(resultAccount);
AccountVO returnAccount = accountDao.selectBalance(accountVO);
logger.info(">>>>>>uid->" + resultAccount.getUid() + " transfMoney->"
+ resultAccount.getTransfMoney() + " current balance->" + returnAccount.getBalance()
+ " versionNo->" + returnAccount.getVersionNo() + " affectedRows->" + affectedRows);
returnAccount.setTransfMoney(accountVO.getTransfMoney());
return new ResponseBean(0, "扣款成功", returnAccount);
} else {
return new ResponseBean(101, "扣款>余额");
}
} else {
return new ResponseBean(102, "余额为0");
}
}
} catch (Exception e) {
logger.error(">>>>>>updateBalance error: " + e.getMessage(), e);
throw new Exception(">>>>>>updateBalance error: " + e.getMessage(), e);
} finally {
try {
lock.unlock();
} catch (Exception e) {
}
}
}
此处重要的信息为:boolean islock = lock.tryLock(0, TimeUnit.SECONDS); 一定要这样写redission锁才会变成“自动续约锁”机制,即tryLock()方法中的第一个参数为0,即你的事务没有运行完毕,redis里的锁会在你的事务还没有结束时每次自动加10秒、加10秒,直到你的事务做完。即使你的redis或者是应用服务挂了,它也会过10-30秒左右自动释放,永不会锁死。
来看运行起来的效果
同样我们使用“并发线程”,3分钟压测 ,它将产生9,300个请求。
运行后效果如下
?客户端返回10条扣款请求成功,其余不是“的动作太快了,请稍侯“就是”余额为0或者是扣款>余额“,整个并发的过程共产生了9,300个请求。全测试过程顺利完成。
这边需要多说一下,由于本人的开发用电脑用的硬盘的IOPS有24,000转,因此在本人开发电脑上redis自动续约锁和使用数据的cas-乐观锁性能上相差比较不大(没错,我自己用的开发电脑确实高于大多企业的服务器性能)。实际在真实的生产服务器上这两个方案的区别是使用redis自动续约锁的性能比使用数据库乐观锁要高出百分之10几。
总结一下几种做法的区别和使用场景
业务场景 | 性能 | 业务幂等 | 会不会卡死服务器 | 选择方案时的考虑因素 | 不用锁做并发抢券、扣款类 | 高 | 无 | 不会 | 考虑都不会考虑 | 用悲观锁 | 极低 | 有 | 极易卡死系统 | 只有线下柜面POS机或者是银行的高柜业务使用的黑屏POS机使用这种方案 | 用CAS-乐观锁 | 高 | 有 | 不会 | 如果系统无法引入redis和redission组件,且改造业务方法太复杂那么可以考虑使用CAS-乐观锁方案,它造成的系统改动不大 | 用Redis自动续约锁 | 高 | 有 | 不会 | 如果系统是天然的spring boot2.X且改造成本不高,强烈推荐使用此方案 |
?
|