大多数程序员,做得最多的事,也不过是写接口这件事而已。
今天继续总结下接口设计需要注意的点。尽量每种都给出具体的场景、案例等,希望大家能有所收获。
1、接口幂等
幂等性:是指一个操作或者一个服务,无论执行多少次,其结果都是一样的,对系统产生的影响是相同的。
这对于确保系统的稳定性和可靠性至关重要,尤其是在分布式系统、微服务架构以及涉及支付、订单处理等关键业务场景中。
比如对最典型的支付场景而言,就需要避免重复支付的问题:一个订单,后续的多次付款请求,只能有一次成功。
以下是需要考虑幂等性的几个典型场景:
- 支付接口:重复的支付请求可能导致用户被多次扣款。确保支付操作是幂等的,可以避免用户因网络延迟或错误点击造成的重复扣费问题。
- 订单创建与更新:在电商系统中,用户可能因为网络问题提交了多次订单请求。幂等设计可以保证即使请求多次,也只创建或更新一次订单状态。
- 消息队列:消息生产者可能因为网络波动等原因重发消息,消息消费者需要能够识别并忽略重复的消息,确保业务逻辑只被执行一次。
- 缓存更新:在更新缓存时,如果因为网络问题导致更新操作未确认成功而重试,幂等性可以确保缓存不会被错误地多次修改。
- 用户注册与登录:虽然这些操作通常不期望是幂等的(例如,注册不能重复创建账户),但在实现过程中,如验证码验证、token刷新等功能应当设计为幂等,以防止因多次请求导致的问题。
- 数据库写入操作:特别是那些具有唯一约束的操作,如插入唯一索引的记录,幂等设计能防止因重复插入而导致的冲突错误。
- RESTful API设计:特别是PUT和DELETE方法,按照HTTP规范,它们应该具备幂等性。PUT请求多次应该得到相同的结果,而DELETE请求多次也应该保持资源的删除状态不变。
针对上述需要考虑幂等性的场景,可以采取以下几种策略来设计和实现幂等性:
- 使用事务: 在数据库操作中,通过ACID(原子性、一致性、隔离性、持久性)属性确保操作的幂等性。例如,在订单创建时,通过事务包裹插入操作,即使操作重复执行,数据库也能保证订单只被创建一次。
- 唯一键约束: 对于可能产生重复数据的写入操作,利用数据库中的唯一索引或主键约束。当尝试插入已存在的记录时,数据库会阻止重复插入,从而实现幂等。
- 乐观锁/悲观锁: 在并发更新时,乐观锁通过版本号或时间戳字段检查数据是否被改动过,只有当数据未被其他事务修改时才执行更新;悲观锁则是在事务开始时就锁定资源,阻止其他事务修改,直到当前事务结束。这两种机制都能确保更新操作的幂等性。
- Token机制: 在处理敏感操作如支付、订单提交时,先向服务器请求一个一次性Token,之后的请求携带此Token进行操作。服务器验证Token的有效性和使用次数,确保操作只被执行一次。 可用于用户注册和登录的场景。
- 记录操作日志: 记录每一次操作的状态和结果,当接收到重复请求时,首先检查操作日志,如果发现该操作已经成功执行,则直接返回之前的成功结果,不再重复执行业务逻辑。 比如记录订单的状态,基于订单的状态判断后续操作的可行性。
- 幂等键: 在API设计中,特别是在POST和PATCH请求中,客户端可以提供一个唯一的idempotent key(幂等键)。服务端根据这个key判断请求是否已经处理过,避免重复处理。
- 消息幂等处理: 在消息队列中,为每条消息分配一个全局唯一的ID,并在消费端记录已处理的消息ID。当消息重新投递时,消费端通过检查ID判断是否已经处理过,从而避免重复消费。
- 重试策略与去重: 对于可能会因为网络原因重试的请求,如支付、消息发送等,服务端需要有机制识别并忽略重复的请求。这可以通过记录请求指纹、设置请求超时和重试次数上限等方式实现。
2、读写分离
使用读写分离主要是为了减轻主数据库的压力,当需要考虑读写分离的时,通常涉及数据库层面的优化。
以下是实现读写分离的一些策略和实践方法:
- 使用数据库中间件
-
MyCAT、ShardingSphere、dynamic-datasource:这类中间件能够实现MySQL数据库的读写分离,自动路由读操作到从库,写操作到主库。通过配置中间件,Java应用只需连接到中间件,而无需直接管理数据库连接的读写分离逻辑。
- 读写分离策略
-
主从延迟问题处理:读写分离后,从库的数据同步通常会有一定延迟,需要在应用层考虑这一因素,比如对实时性要求高的查询尽量走主库。
-
负载均衡:对于读操作,可以配置从库的负载均衡策略,比如轮询、随机或者基于权重的分配,以充分利用所有从库资源。
- 事务管理
- 两阶段提交(2PC):虽然在分布式系统中使用2PC来保证跨库事务的一致性较为复杂且影响性能,但在某些严格要求事务一致性的场景下可能需要考虑。
- 灵活事务策略:对于非严格事务场景,可以采用补偿事务或者最大努力通知等模式,牺牲一定的强一致性来换取性能。
比如使用多数据源查看数据:
@Service
@DS("master")
public class UserServiceImpl implements UserService {
@Autowired
private JdbcTemplate jdbcTemplate;
public List<Map<String, Object>> selectAll() {
return jdbcTemplate.queryForList("select * from user");
}
@Override
@DS("slave")
public List<Map<String, Object>> selectByCondition() {
return jdbcTemplate.queryForList("select * from user where age >10");
}
}
3、数据缓存
Redis主要因为它作为一个高性能的键值存储系统,提供了丰富的数据结构(如字符串、哈希、列表、集合、有序集合等)以及强大的特性(如事务、持久化、Lua脚本、发布/订阅等),非常适合解决各种缓存、消息队列、会话存储、分布式锁等方面的问题。
下面列举了一些常见的使用场景:
- 数据缓存:最典型的使用场景之一。将频繁访问且不经常改变的数据存储在Redis中,减少数据库的负载,提高应用性能。例如,商品详情、用户信息、分类树、组架树等。
- 会话存储:可以将用户的会话信息存储在Redis中,实现无状态服务,方便水平扩展。同时利用Redis的持久化机制保证数据不丢失。
- 分布式锁:利用Redis的原子操作能力,实现分布式环境下的锁机制,用于控制并发操作,如秒杀、库存扣减等场景。
- 消息队列:Redis的发布/订阅功能可以用来构建简单的消息队列系统,实现异步处理和解耦合。虽然不是专门的消息队列服务,但在某些轻量级场景下非常实用。
- 计数器与限流:利用Redis的incr、decr等原子操作实现高并发下的计数需求,如统计访问次数、限流(如限制API调用频率)等。
- 排行榜与Leaderboard:利用有序集合数据类型可以轻松实现各类排行榜,如游戏积分榜、文章热度排名等,支持快速查询排名和分数区间。
- 布隆过滤器:可以利用Redis的HyperLogLog或者结合布隆过滤器的库来实现数据去重、判断元素是否存在等功能,尤其是在大数据场景下非常高效。
- 实时分析与聚合:虽然Redis主要用于存储,但其数据结构如有序集合可以支持一些实时分析的需求,比如统计网站在线人数、最近活跃用户等。
使用缓存的时候,需要注意缓存和数据库数据的一致性,在修改内容的时候要及时更新缓存,特别是新增加修改接口的时候,不要忘记了更新缓存。
使用spring的Cache组件进行缓存数据:
@CacheEvict(cacheNames = CACHE_NAME_FRONT_CATEGORY, allEntries = true)
public Long saveAndUpdate(CategoryFrontReq req) throws ServiceException {
log.info("新增或修改类目开始CategoryReq{}", req);
// 业务逻辑
log.info("新增或修改类目成功result{}", result);
return result;
}
4、接口分页
如果一个接口对外返回一个数组,千万注意数据量的大小,新接手项目写接口的时候,要查一查生产环境的数据量。不要因为一时疏忽,直接导致一个生产环境的OOM事故产生。
分页容易出现的问题包括:
- 深分页
- 关联表过多
5、接口加锁
回家不锁门,小心被偷家。既然是独有的私密空间,就应该自己独享,那句话怎么说的来着,卧榻之侧,岂容他人鼾睡。哈哈,就是这个意思。
以下是一些具体场景:
- 库存管理:在线购物系统中,当用户下单时,需要减少商品库存。由于同一商品可能同时被多个用户访问和购买,对库存的修改操作就需要加锁,确保不会出现超卖的情况。
- 订单处理:在处理订单状态变更时(如从“未支付”变为“已支付”),为了防止并发操作导致的订单状态混乱,对订单状态更新的操作需要加锁。
- 账户资金操作:银行或支付系统中,转账、存款、取款等操作涉及到账户余额的增减,这些操作需要原子性执行,以确保不会因为并发操作导致账户余额计算错误。
- 票务系统:如火车票、电影票等票务预订过程中,座位的锁定和释放操作需要加锁,确保不会出现重复售卖同一座位的问题。
- 消息队列处理:在处理消息队列(如任务调度、事件驱动架构)时,消费和确认消息的操作可能需要加锁,确保消息只被一个消费者正确处理。
- 缓存更新:当有多个线程可能同时读取并更新缓存中的数据时,如热点商品的缓存刷新,需要加锁以防止脏读或数据覆盖问题。
- 计数器与统计:如网站访问计数、点赞数、评论数等,这些统计量的累加操作如果不加以控制,可能会因为并发更新而丢失更新,加锁可以保证计数的准确性。
- 会话管理:在Web应用中,对用户会话信息的修改(如登录状态、购物车内容)需要加锁,确保多个请求对同一会话的操作不会冲突。
- 并发任务调度:在复杂的任务调度系统中,分配任务给工作者线程、记录任务状态变更等操作可能需要加锁,以避免任务被重复分配或状态更新混乱。
- 分布式锁应用场景:在分布式系统中,对于全局资源的访问和修改(如分布式缓存、数据库中的全局序列号生成)也需要通过分布式锁来协调不同节点间的并发访问。
在涉及并发控制的场景下使用加锁机制时,也需要注意一下几个问题:
- 死锁:当两个或多个进程互相等待对方持有的锁时,就会发生死锁。例如,进程A持有资源X并等待资源Y,而进程B持有资源Y并等待资源X,如果没有外部干预,两个进程都会无限期地等待下去。
- 过度使用锁:不必要的广泛使用锁会导致性能下降。如果对不需要同步的代码块也进行了加锁,会增加线程之间的竞争和上下文切换的开销。
- 锁粒度过大:如果一个锁保护了过多的数据或代码段,可能会导致不必要的阻塞。理想的状况是使用细粒度的锁,只保护必要的资源。
6、事务管理
在Java应用开发中,事务管理是确保数据一致性和完整性的重要环节
- 本地事务
本地事务通常涉及单个数据库,所有操作都在同一个数据库连接中完成。在Java应用中,通过JDBC直接操作数据库或者使用如Spring的@Transactional注解来管理事务。
- 分布式事务
分布式事务涉及跨越多个数据库或服务的操作,需要在多个节点上保持数据的一致性。Java应用中常用的技术包括两阶段提交(2PC,如JTA+XA)、Saga模式、TCC(Try-Confirm-Cancel)模式等。可用使用seata等分布式事务组件实现。
主要适用于以下场景:
- 跨库操作:当业务操作需要同时在多个数据库上执行,并且这些操作必须全部成功或全部失败时,就需要分布式事务。例如,一个订单系统可能需要同时更新订单数据库、库存数据库和用户积分数据库。
- 微服务架构:在微服务架构中,一个业务功能往往由多个服务协同完成,每个服务可能有自己的数据库。在这种情况下,一个业务流程可能跨越多个服务,因此需要分布式事务来保证事务的ACID特性。
- 跨服务调用:如果一个事务操作需要调用多个远程服务,并且这些服务操作之间存在依赖关系,那么分布式事务是必需的。例如,在电商平台中,下单操作可能需要调用库存服务减少库存、支付服务处理支付、物流服务安排发货等多个步骤。
- 复杂业务流程:对于那些包含多个步骤,且每个步骤都需要在不同服务或数据库中持久化数据的长事务或复杂业务流程,分布式事务提供了一种解决一致性问题的方法。
@GlobalTransactional // seata分布式事务注解
public OrderCreateResponse createBookOrder(CardBookReq cardBookReq) throws Exception {
// 业务逻辑
}
7、设计模式
设计模式在代码中的使用也比较常见,能够对场景进行抽象,然后给出通用的解决方案。
下面是代码中场景的一些设计模式的使用场景:
- 单例模式:全局唯一的配置项、数据库连接池、线程池、缓存管理、日志记录器等。在这些场景中,需要确保无论何时何地都只有一个实例存在,且这个实例易于访问。
- 工厂模式:当需要创建对象而不希望客户端知道具体实现时;当需要动态决定创建哪种对象时;当需要封装对象的创建过程时。例如,创建不同种类的数据库连接、创建不同操作系统的UI组件等。
- 建造者模式:当需要构建复杂对象,且对象的构建过程需要设置多个选项或属性时;当这些选项或属性有多种组合时。例如,创建具有复杂配置的服务器对象、创建具有多个字段的表单对象等。
- 适配器模式:当需要将一个类的接口转换成客户端所期望的另一个接口时;当两个类之间因为接口不兼容而无法协同工作时。例如,旧接口与新系统之间的适配、不同数据库之间的数据转换等。
- 观察者模式:当一个对象的状态改变时,需要通知其他依赖它的对象时;当需要实现发布-订阅模型时。例如,用户界面中的监听器(如按钮点击事件监听器)、股票价格变动通知等。
- 策略模式:当需要在运行时根据条件选择不同算法或行为时;当需要将算法与使用该算法的对象分离时。例如,排序算法的选择(冒泡排序、快速排序等)、支付方式的选择(信用卡支付、支付宝支付等)。
8、多线程编程
多线程技术在许多业务场景中能够显著提升应用的响应速度和处理能力,尤其是在需要同时执行多项任务,或者处理大量并发请求的情况下。
下面是一些需要考虑使用多线程的业务场景:
- 文件上传/下载:文件上传或下载过程中,特别是在处理大文件时,可以利用多线程分块上传或下载,加快传输速度,同时保持用户界面的响应性。
- 异步处理:在需要执行一些耗时操作(如发送邮件、生成报告等)但又不想阻塞主线程时,可以开启新线程异步执行这些任务,从而提高用户体验。
- 数据批处理:在大数据处理、ETL作业中,通过多线程并行处理数据,可以大大减少处理时间,特别是在数据清洗、转换、加载到数据库等步骤。
- 即时通讯服务:即时通讯软件需要快速处理消息收发、群聊广播等功能,多线程可以有效应对高并发的消息处理需求,保证消息的实时性。
多线程编程虽然强大,但也存在一些潜在的问题和陷阱,如果不小心处理,可能会导致程序行为异常,数据不一致,甚至死锁等问题。
以下是使用Java多线程时容易遇到的一些“坑”:
- 线程泄漏:未正确管理线程,导致线程创建后没有被正确终止或回收,长期累积可能导致系统资源耗尽。
- 滥用线程池:不恰当的线程池配置(如线程数过多或过少)、任务队列无界导致内存溢出、长时间运行的任务阻碍了短任务快速执行等,都是常见的问题。
- 非线程安全的类误用:误将非线程安全的集合类(如ArrayList、HashMap)当作线程安全的使用,也是常见的错误。