您现在的位置是:首页 >技术杂谈 >项目中遇到的一些问题总结(七)网站首页技术杂谈
项目中遇到的一些问题总结(七)
MySQL 幻读
幻读(Phantom Read)是指在同一事务中,针对一个表,多次执行某个查询时结果集不同,导致出现“幻行”的情况。通俗点说,就是一个事务在某个字段上执行了查询,得到一组数据,但是其它事务新增、修改、删除了这个数据区间内的记录,再次执行同样的查询就会得到不一样的结果集,而且新出现的记录就像幻像一样从不存在变成了存在,出现了幻读的情况。
Mysql幻读的原因是在REPEATABLE READ(可重复读)隔离级别下,我们执行select语句时会对读取的记录加上共享锁(Shared Lock),而仅仅是防止了其他事务对这些记录的删除操作和UPDATE操作,但是无法阻止其他事务对这个表新增记录或修改不在我们的语句中的字段或者条件。
例如,一个事务在REPEATABLE READ隔离级别下执行如下查询语句:
SELECT * FROM table1 WHERE gmt_create BETWEEN '2022-01-01' AND '2022-01-31';
然后在另一个事务中对满足以上条件的一个记录执行如下操作:
INSERT INTO table1 (gmt_create, ...) VALUES ('2022-01-15', ...);
在这种情况下,第一个事务在再次执行相同查询时,结果集会包含新增的一行记录,这就是幻读的情况。
我们可以通过将隔离级别设置为SERIALIZABLE(串行化)来避免幻读问题的出现,SERIALIZABLE隔离级别会锁定整个表,在事务提交或回滚前其它事务无法对表进行任何操作,这样可以避免出现幻读的情况。但是这样做的缺点是可能会导致较高的并发性能下降,因此需要在使用之前权衡利弊。
同一个类非事务方法调用事务方法 @Transactional 注解失效解决办法
AopContext.currentProxy()
方法可以获取当前Spring管理的代理对象,例如使用@Transactional
注解进行事务管理时,在同一个类中调用带有该注解的方法,会导致注解失效,这是因为Spring并不会对同一类中的方法当做代理处理,而是直接调用了该方法,导致事务无法生效。这种情况下,我们可以通过AopContext.currentProxy()
获取当前对象的代理对象,并调用代理对象的方法,以达到事务生效的目的。
例如,如果我们有一个UserService接口和其实现类UserServiceImpl,其中带有@Transactional
注解的方法为insert(name, age),在UserServiceImpl类中的其它方法中也需要调用该方法并保证事务生效,我们可以通过以下方式来实现:
public class UserServiceImpl implements UserService {
@Autowired
private UserService userService;
@Override
@Transactional
public void insert(String name, int age) {
// 该方法需要在事务范围内执行
}
public void otherMethod() {
// 此处调用insert方法需要事务生效
((UserService) AopContext.currentProxy()).insert(name, age);
}
}
这里我们通过AopContext.currentProxy()
获取代理对象,并强制转换为UserService类型,然后再调用insert方法,这样就可以保证事务生效了。但是需要注意,该方法需要保证当前线程已经处于事务的范围内,如果当前线程并没有事务,则会抛出异常。所以在使用这种方式时需要格外小心,避免出现问题。
还有很多文章说可以在 UserServiceImpl 实现类中注入 UserService:
public class UserServiceImpl implements UserService {
@Autowired
private UserService userService;
@Override
@Transactional
public void insert(String name, int age) {
// 该方法需要在事务范围内执行
}
public void otherMethod() {
// 此处调用insert方法需要事务生效
userService.insert(name, age);
}
}
但是我试了一直不成功,搞了好久,不知道是不是我哪里写错了,这种方法大家也可以试试,如果可以的话,可以告诉我一下。
视频文件的编码格式和文件格式
视频文件编码格式和文件格式是两个不同的概念。编码格式是指视频文件使用什么算法将视频信号转换为数字化数据,而文件格式则是指视频文件的存储格式、容器格式。下面简单介绍一下它们的区别:
- 编码格式
常见的视频编码格式有:
- MPEG-1: 用于视频CD的压缩格式,分辨率为352×240。
- MPEG-2: 用于DVD、蓝光光盘等的压缩格式,分辨率为720×480。
- MPEG-4: 用于互联网视频传输的压缩格式,具有更高的压缩比和更好的画质。
- H.264: 一种高效的视频压缩格式,应用广泛,包括蓝光光盘、Apple设备、Youtube、Vimeo等。
- AV1: 最新的视频编码格式,由Google开发,可提供更高的画质和更高的压缩比。
- 文件格式
常见的视频文件格式有:
- AVI: Microsoft开发的视频格式,支持多种编码格式。
- MP4: 一种常用的视频文件格式,可支持多种编码格式,容器灵活。
- MKV: 一种开源、免费的视频容器格式,支持多种音视频编码格式。
- MOV: Apple QuickTime开发的视频格式,支持多种编码格式,比如H.264、ProRes等。
- FLV: Adobe Flash Player支持的视频格式,主要用于网络视频的流媒体传输。
以上是视频编码格式和文件格式的简单介绍,它们的选择取决于不同领域的需求和使用场景。
介绍一下乐观锁
乐观锁是一种并发控制机制,它基于假设同时执行的线程之间不会产生冲突,因此不会使用显式的锁机制来进行同步控制或阻塞线程。相反,它使用一种标记机制,即在对共享资源进行修改之前,会对资源的版本标记进行检查,确保当前的版本与线程开始执行时的版本匹配。
我们可以使用乐观锁来解决并发修改数据时的问题,其中既包括在多线程环境下的数据库修改,也包括在类似于集合、数组和其他共享数据结构的应用程序层上的修改问题。
下面是一个简单的乐观锁示例,假设有一个Account类表示用户的银行账户信息:
public class Account {
private Long id;
private String name;
private BigDecimal balance;
private int version;
// 省略getter/setter方法和其它构造函数
public void deposit(BigDecimal amount) {
this.balance = this.balance.add(amount);
// 操作完成后递增版本号
this.version++;
}
public void withdraw(BigDecimal amount) {
this.balance = this.balance.subtract(amount);
// 操作完成后递增版本号
this.version++;
}
}
在这个账户类中,deposit()和withdraw()方法用于增加和减少账户余额,每次成功执行这些方法后都会递增version版本号。
这样一来,当两个线程试图同时修改同一个账户的余额时,版本号就可以用来检测到冲突并避免数据不一致的情况。
下面演示一个简单的乐观锁示例:
假设有一个在线商城系统,需要实现多用户并发下的库存计算,使用乐观锁可以有效避免出现超卖或卖出不存在的商品等异常情况。
首先,我们需要一个商品类Product,它包含商品编号、商品名称、库存数量和版本号等属性:
public class Product {
private int id;
private String name;
private int stock;
private int version;
public Product(int id, String name, int stock, int version) {
this.id = id;
this.name = name;
this.stock = stock;
this.version = version;
}
// 省略getter/setter方法和其它构造函数
// 减少库存
public void decreaseStock(int amount) {
this.stock -= amount;
this.version++;
}
}
这里提供了一个减少库存数量的方法decreaseStock,每次执行成功后都会递增版本号。
实际的库存计算逻辑可以在一个简单的业务方法中实现:
public void purchase(Product product, int amount) {
while (true) {
int currentVersion = product.getVersion();
if (product.getStock() < amount) {
throw new RuntimeException("商品库存不足");
}
product.decreaseStock(amount);
// 尝试更新库存和版本号
if (!updateStockAndVersion(product, currentVersion)) {
// 如果更新失败,说明版本号已被其它线程修改过,需重试
System.out.println(Thread.currentThread().getName() + " - 更新库存和版本号失败,重试");
product = getProductById(product.getId());
} else {
System.out.println(Thread.currentThread().getName() + " - 购买商品成功");
break;
}
}
}
// 更新库存和版本号
private boolean updateStockAndVersion(Product product, int currentVersion) {
String sql = "update product set stock=?, version=? where id=? and version=?";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, product.getStock());
ps.setInt(2, product.getVersion());
ps.setInt(3, product.getId());
ps.setInt(4, currentVersion);
int rows = ps.executeUpdate();
return rows > 0;
} catch (SQLException e) {
e.printStackTrace();
return false;
}
}
在购买商品时,我们先检查商品库存是否足够,然后再调用Product类的decreaseStock方法减少库存数量并递增版本号,接着使用updateStockAndVersion方法更新库存和版本号到数据库中。
如果更新库存和版本号失败(返回值为false),说明版本号已被其它线程修改过,此时需要重新查询该商品并重复以上操作,直到更新成功为止。
这个示例中,递增版本号的实现是使每次在修改共享资源时递增版本号,然后在尝试更新时,检查该版本号是否符合预期。如果不符合,说明有线程已经修改过该共享资源,需要重新尝试修改。这样可以避免数据不一致的问题。